diff --git a/frontend/modules/commercial/components/ClientAddressBlock.vue b/frontend/modules/commercial/components/ClientAddressBlock.vue index dcc268d..abb307b 100644 --- a/frontend/modules/commercial/components/ClientAddressBlock.vue +++ b/frontend/modules/commercial/components/ClientAddressBlock.vue @@ -127,7 +127,7 @@ empty-option-label="" :required="!readonly && !disabled" :error="errors?.city" - @update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))" + @update:model-value="onCityChange" /> (field: K, value: AddressFormDr emit('update:modelValue', { ...props.modelValue, [field]: value }) } +/** + * Selection d'une ville (select assiste BAN) → vide adresse + complement, devenus + * incoherents avec la nouvelle ville. Ne reagit qu'a un vrai changement de valeur. + * En mode degrade (saisie libre), la ville reste un simple `update` (pas de reset + * a chaque frappe). + */ +function onCityChange(value: string | number | null): void { + const next = value === null ? null : String(value) + if (next === (props.modelValue.city ?? null)) { + return + } + banAddressOptions.value = [] + lastAddressSuggestions = [] + emit('update:modelValue', { + ...props.modelValue, + city: next, + street: null, + streetComplement: null, + }) +} + /** Revele le 2e champ email de facturation (clic sur le « + »). */ function revealSecondaryBillingEmail(): void { emit('update:modelValue', { ...props.modelValue, hasSecondaryBillingEmail: true }) @@ -327,9 +348,27 @@ function notifyUnavailable(): void { /** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */ async function onPostalCodeChange(value: string): Promise { - update('postalCode', value) - const digits = (value ?? '').replace(/\D/g, '') + const previousDigits = (props.modelValue.postalCode ?? '').replace(/\D/g, '') + + // CP complet (5 chiffres) et reellement modifie → ville, adresse et complement + // deviennent incoherents avec le nouveau code postal : on les vide pour forcer + // une re-saisie coherente (on n'efface pas pendant une correction partielle). + if (digits.length === 5 && digits !== previousDigits) { + banAddressOptions.value = [] + lastAddressSuggestions = [] + emit('update:modelValue', { + ...props.modelValue, + postalCode: value, + city: null, + street: null, + streetComplement: null, + }) + } + else { + update('postalCode', value) + } + if (digits.length < 5) { return } diff --git a/frontend/modules/commercial/components/SupplierAddressBlock.vue b/frontend/modules/commercial/components/SupplierAddressBlock.vue index 11883e8..f138f86 100644 --- a/frontend/modules/commercial/components/SupplierAddressBlock.vue +++ b/frontend/modules/commercial/components/SupplierAddressBlock.vue @@ -98,7 +98,7 @@ empty-option-label="" :required="!readonly && !disabled" :error="errors?.city" - @update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))" + @update:model-value="onCityChange" /> (field: K, value: Suppl emit('update:modelValue', { ...props.modelValue, [field]: value }) } +/** + * Selection d'une ville (select assiste BAN) → vide adresse + complement, devenus + * incoherents avec la nouvelle ville. Ne reagit qu'a un vrai changement de valeur. + * En mode degrade (saisie libre), la ville reste un simple `update` (pas de reset + * a chaque frappe). + */ +function onCityChange(value: string | number | null): void { + const next = value === null ? null : String(value) + if (next === (props.modelValue.city ?? null)) { + return + } + banAddressOptions.value = [] + lastAddressSuggestions = [] + emit('update:modelValue', { + ...props.modelValue, + city: next, + street: null, + streetComplement: null, + }) +} + /** Previent le parent (toast unique) que l'autocompletion est indisponible. */ function notifyUnavailable(): void { if (!unavailableNotified) { @@ -277,9 +298,27 @@ function notifyUnavailable(): void { /** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */ async function onPostalCodeChange(value: string): Promise { - update('postalCode', value) - const digits = (value ?? '').replace(/\D/g, '') + const previousDigits = (props.modelValue.postalCode ?? '').replace(/\D/g, '') + + // CP complet (5 chiffres) et reellement modifie → ville, adresse et complement + // deviennent incoherents avec le nouveau code postal : on les vide pour forcer + // une re-saisie coherente (on n'efface pas pendant une correction partielle). + if (digits.length === 5 && digits !== previousDigits) { + banAddressOptions.value = [] + lastAddressSuggestions = [] + emit('update:modelValue', { + ...props.modelValue, + postalCode: value, + city: null, + street: null, + streetComplement: null, + }) + } + else { + update('postalCode', value) + } + if (digits.length < 5) { return } diff --git a/frontend/modules/commercial/components/__tests__/ClientAddressBlock.spec.ts b/frontend/modules/commercial/components/__tests__/ClientAddressBlock.spec.ts index 782349a..85c280e 100644 --- a/frontend/modules/commercial/components/__tests__/ClientAddressBlock.spec.ts +++ b/frontend/modules/commercial/components/__tests__/ClientAddressBlock.spec.ts @@ -171,6 +171,182 @@ describe('ClientAddressBlock — mapping erreur par champ (ERP-101)', () => { }) }) +/** + * Stub MalioInputText emetteur : re-expose `label` et relaie `update:model-value`, + * pour piloter le champ Code postal et observer le brouillon emis. + */ +const MalioInputTextEmitter = defineComponent({ + name: 'MalioInputTextEmitter', + props: { + modelValue: { type: [String, Number, null], default: undefined }, + label: { type: String, default: '' }, + }, + emits: ['update:modelValue'], + setup(props) { + return () => h('div', { 'data-testid': 'addr-input', 'data-label': props.label }) + }, +}) + +describe('ClientAddressBlock — changement de code postal vide les champs dependants (ERP-193)', () => { + beforeEach(() => { + searchCityMock.mockReset() + searchCityMock.mockResolvedValue([]) + }) + + function mountFilled() { + return mount(ClientAddressBlock, { + props: { + modelValue: { + ...emptyAddress(), + postalCode: '75001', + city: 'Paris', + street: '8 Boulevard du Port', + streetComplement: 'Bat A', + }, + title: 'Adresse', + categoryOptions: [], + siteOptions: [], + contactOptions: [], + countryOptions: [], + }, + global: { + stubs: { + MalioButtonIcon: true, + MalioCheckbox: true, + MalioSelect: true, + MalioSelectCheckbox: true, + MalioInputAutocomplete: MalioInputAutocompleteStub, + MalioInputText: MalioInputTextEmitter, + }, + }, + }) + } + + function postalCodeField(wrapper: ReturnType) { + return wrapper.findAllComponents(MalioInputTextEmitter).find( + c => c.props('label') === 'commercial.clients.form.address.postalCode', + ) + } + + it('vide ville, adresse et complement quand le CP complet change', async () => { + const wrapper = mountFilled() + + postalCodeField(wrapper)!.vm.$emit('update:modelValue', '33000') + await flushPromises() + + const last = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Record + expect(last.postalCode).toBe('33000') + expect(last.city).toBeNull() + expect(last.street).toBeNull() + expect(last.streetComplement).toBeNull() + }) + + it('ne vide pas les champs si le CP reste incomplet (< 5 chiffres)', async () => { + const wrapper = mountFilled() + + postalCodeField(wrapper)!.vm.$emit('update:modelValue', '7500') + await flushPromises() + + const last = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Record + expect(last.postalCode).toBe('7500') + expect(last.city).toBe('Paris') + expect(last.street).toBe('8 Boulevard du Port') + expect(last.streetComplement).toBe('Bat A') + }) + + it('ne vide pas les champs si le CP complet est identique', async () => { + const wrapper = mountFilled() + + postalCodeField(wrapper)!.vm.$emit('update:modelValue', '75001') + await flushPromises() + + const last = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Record + expect(last.city).toBe('Paris') + expect(last.street).toBe('8 Boulevard du Port') + expect(last.streetComplement).toBe('Bat A') + }) +}) + +/** + * Stub MalioSelect emetteur : re-expose `label` et relaie `update:model-value`, + * pour piloter le select Ville et observer le brouillon emis. + */ +const MalioSelectEmitter = defineComponent({ + name: 'MalioSelectEmitter', + props: { + modelValue: { type: [String, Number, null], default: undefined }, + label: { type: String, default: '' }, + }, + emits: ['update:modelValue'], + setup(props) { + return () => h('div', { 'data-testid': 'addr-select', 'data-label': props.label }) + }, +}) + +describe('ClientAddressBlock — changement de ville vide adresse + complement (ERP-193)', () => { + beforeEach(() => { + searchCityMock.mockReset() + searchCityMock.mockResolvedValue([]) + }) + + function mountFilled() { + return mount(ClientAddressBlock, { + props: { + modelValue: { + ...emptyAddress(), + postalCode: '75001', + city: 'Paris', + street: '8 Boulevard du Port', + streetComplement: 'Bat A', + }, + title: 'Adresse', + categoryOptions: [], + siteOptions: [], + contactOptions: [], + countryOptions: [], + }, + global: { + stubs: { + MalioButtonIcon: true, + MalioCheckbox: true, + MalioSelectCheckbox: true, + MalioInputText: true, + MalioInputAutocomplete: MalioInputAutocompleteStub, + MalioSelect: MalioSelectEmitter, + }, + }, + }) + } + + function cityField(wrapper: ReturnType) { + return wrapper.findAllComponents(MalioSelectEmitter).find( + c => c.props('label') === 'commercial.clients.form.address.city', + ) + } + + it('vide adresse et complement quand la ville change', async () => { + const wrapper = mountFilled() + + cityField(wrapper)!.vm.$emit('update:modelValue', 'Lyon') + await flushPromises() + + const last = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Record + expect(last.city).toBe('Lyon') + expect(last.street).toBeNull() + expect(last.streetComplement).toBeNull() + }) + + it('ne vide pas si la ville selectionnee est identique', async () => { + const wrapper = mountFilled() + + cityField(wrapper)!.vm.$emit('update:modelValue', 'Paris') + await flushPromises() + + // Aucun nouvel emit (valeur inchangee) → l'adresse reste intacte. + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) +}) + describe('ClientAddressBlock — recherche adresse robuste (erreur BAN)', () => { beforeEach(() => { searchAddressMock.mockReset() diff --git a/frontend/modules/technique/components/ProviderAddressBlock.vue b/frontend/modules/technique/components/ProviderAddressBlock.vue index ba1c2dc..7e7146c 100644 --- a/frontend/modules/technique/components/ProviderAddressBlock.vue +++ b/frontend/modules/technique/components/ProviderAddressBlock.vue @@ -79,7 +79,7 @@ empty-option-label="" :required="!readonly && !disabled" :error="errors?.city" - @update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))" + @update:model-value="onCityChange" /> (field: K, value: Provi emit('update:modelValue', { ...props.modelValue, [field]: value }) } +/** + * Selection d'une ville (select assiste BAN) → vide adresse + complement, devenus + * incoherents avec la nouvelle ville. Ne reagit qu'a un vrai changement de valeur. + * En mode degrade (saisie libre), la ville reste un simple `update` (pas de reset + * a chaque frappe). + */ +function onCityChange(value: string | number | null): void { + const next = value === null ? null : String(value) + if (next === (props.modelValue.city ?? null)) { + return + } + banAddressOptions.value = [] + lastAddressSuggestions = [] + emit('update:modelValue', { + ...props.modelValue, + city: next, + street: null, + streetComplement: null, + }) +} + /** Previent le parent (toast unique) que l'autocompletion est indisponible. */ function notifyUnavailable(): void { if (!unavailableNotified) { @@ -228,9 +249,27 @@ function notifyUnavailable(): void { /** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville (RG-3.06). */ async function onPostalCodeChange(value: string): Promise { - update('postalCode', value) - const digits = (value ?? '').replace(/\D/g, '') + const previousDigits = (props.modelValue.postalCode ?? '').replace(/\D/g, '') + + // CP complet (5 chiffres) et reellement modifie → ville, adresse et complement + // deviennent incoherents avec le nouveau code postal : on les vide pour forcer + // une re-saisie coherente (on n'efface pas pendant une correction partielle). + if (digits.length === 5 && digits !== previousDigits) { + banAddressOptions.value = [] + lastAddressSuggestions = [] + emit('update:modelValue', { + ...props.modelValue, + postalCode: value, + city: null, + street: null, + streetComplement: null, + }) + } + else { + update('postalCode', value) + } + if (digits.length < 5) { return } diff --git a/frontend/modules/transport/components/CarrierAddressBlock.vue b/frontend/modules/transport/components/CarrierAddressBlock.vue index b56c5c7..a311f69 100644 --- a/frontend/modules/transport/components/CarrierAddressBlock.vue +++ b/frontend/modules/transport/components/CarrierAddressBlock.vue @@ -36,7 +36,7 @@ empty-option-label="" :required="!readonly && !disabled" :error="errors?.city" - @update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))" + @update:model-value="onCityChange" /> (field: K, value: Carrie emit('update:modelValue', { ...props.modelValue, [field]: value }) } +/** + * Selection d'une ville (select assiste BAN) → vide adresse + complement, devenus + * incoherents avec la nouvelle ville. Ne reagit qu'a un vrai changement de valeur. + * En mode degrade (saisie libre), la ville reste un simple `update` (pas de reset + * a chaque frappe). + */ +function onCityChange(value: string | number | null): void { + const next = value === null ? null : String(value) + if (next === (props.modelValue.city ?? null)) { + return + } + banAddressOptions.value = [] + lastAddressSuggestions = [] + emit('update:modelValue', { + ...props.modelValue, + city: next, + street: null, + streetComplement: null, + }) +} + /** Previent le parent (toast unique) que l'autocompletion est indisponible. */ function notifyUnavailable(): void { if (!unavailableNotified) { @@ -181,9 +202,27 @@ function notifyUnavailable(): void { /** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */ async function onPostalCodeChange(value: string): Promise { - update('postalCode', value) - const digits = (value ?? '').replace(/\D/g, '') + const previousDigits = (props.modelValue.postalCode ?? '').replace(/\D/g, '') + + // CP complet (5 chiffres) et reellement modifie → ville, adresse et complement + // deviennent incoherents avec le nouveau code postal : on les vide pour forcer + // une re-saisie coherente (on n'efface pas pendant une correction partielle). + if (digits.length === 5 && digits !== previousDigits) { + banAddressOptions.value = [] + lastAddressSuggestions = [] + emit('update:modelValue', { + ...props.modelValue, + postalCode: value, + city: null, + street: null, + streetComplement: null, + }) + } + else { + update('postalCode', value) + } + if (digits.length < 5) { return }