feat(commercial) : types d'adresse Courtier et Distributeur (ERP-119)

Ajoute deux types d'adresse autonomes (exclusifs, comme la Prospection) :
- back : colonnes is_broker / is_distributor sur client_address (migration
  modulaire, append + 2 CHECK miroir d'exclusivite + COMMENT ON COLUMN),
  proprietes ClientAddress (getters Groups + SerializedName), Callback
  validateExclusiveAddressTypes, validateAddressTypeRequired etendue,
  catalogue des commentaires SQL mis a jour.
- front : type AddressType (+broker/distributor), drapeaux, mappers, option
  du select Type d'adresse, labels i18n, payloads create/edit et lecture.
- tests back (acceptation + exclusivite + contrat de serialisation) et front.
This commit is contained in:
2026-06-09 13:37:24 +02:00
parent 982f501b94
commit f6be671230
14 changed files with 314 additions and 52 deletions
@@ -218,6 +218,8 @@ const addressTypeOptions = computed<RefOption[]>(() => [
{ value: 'delivery', label: t('commercial.clients.form.address.addressTypeDelivery') },
{ value: 'billing', label: t('commercial.clients.form.address.addressTypeBilling') },
{ value: 'delivery_billing', label: t('commercial.clients.form.address.addressTypeDeliveryBilling') },
{ value: 'broker', label: t('commercial.clients.form.address.addressTypeBroker') },
{ value: 'distributor', label: t('commercial.clients.form.address.addressTypeDistributor') },
])
/** Applique le type choisi en repercutant les 3 drapeaux back (immutabilite). */
@@ -812,6 +812,8 @@ async function submitAddresses(): Promise<void> {
isProspect: address.isProspect,
isDelivery: address.isDelivery,
isBilling: address.isBilling,
isBroker: address.isBroker,
isDistributor: address.isDistributor,
country: address.country,
postalCode: address.postalCode || null,
city: address.city || null,
@@ -30,6 +30,10 @@ export interface AddressFormDraft {
isProspect: boolean
isDelivery: boolean
isBilling: boolean
/** Adresse Courtier — type autonome exclusif. */
isBroker: boolean
/** Adresse Distributeur — type autonome exclusif. */
isDistributor: boolean
country: string
postalCode: string | null
city: string | null
@@ -75,6 +79,8 @@ export function emptyAddress(): AddressFormDraft {
isProspect: false,
isDelivery: false,
isBilling: false,
isBroker: false,
isDistributor: false,
country: 'France',
postalCode: null,
city: null,
@@ -157,7 +157,7 @@ describe('buildContactPayload / buildAddressPayload / buildRibPayload', () => {
it('adresse : email facturation conserve uniquement si requis (RG-1.11)', () => {
const address: AddressFormDraft = {
id: 3, isProspect: false, isDelivery: false, isBilling: true, country: 'France',
id: 3, isProspect: false, isDelivery: false, isBilling: true, isBroker: false, isDistributor: false, country: 'France',
postalCode: '86100', city: 'Châtellerault', street: '1 rue X', streetComplement: null,
categoryIris: ['/api/categories/2'], siteIris: ['/api/sites/1'], contactIris: [],
billingEmail: 'facturation@acme.fr',
@@ -184,7 +184,7 @@ describe('buildContactPayload / buildAddressPayload / buildRibPayload', () => {
// ERP-119 : une adresse partielle omet postalCode/city/street vides (NotBlank).
it('adresse partielle : omet postalCode / city / street vides', () => {
const address: AddressFormDraft = {
id: null, isProspect: false, isDelivery: true, isBilling: false, country: 'France',
id: null, isProspect: false, isDelivery: true, isBilling: false, isBroker: false, isDistributor: false, country: 'France',
postalCode: null, city: '', street: null, streetComplement: null,
categoryIris: ['/api/categories/2'], siteIris: ['/api/sites/1'], contactIris: [],
billingEmail: null,
@@ -20,6 +20,7 @@ import {
isRibRequiredForPaymentType,
omitEmptyRequired,
showsRelationAndTriageFields,
type AddressFlagsDraft,
type AddressValidityDraft,
type ContactDraft,
type ContactFillableDraft,
@@ -149,83 +150,79 @@ describe('hasAtLeastOneValidContact (RG-1.14)', () => {
})
})
/** Drapeaux d'adresse complets (5 types) avec surcharge partielle. */
function flags(overrides: Partial<AddressFlagsDraft> = {}): AddressFlagsDraft {
return {
isProspect: false, isDelivery: false, isBilling: false, isBroker: false, isDistributor: false,
...overrides,
}
}
describe('exclusivite Prospect / Livraison / Facturation (RG-1.06/07/08)', () => {
it('Prospect est selectionnable tant que ni Livraison ni Facturation', () => {
expect(canSelectProspect({ isProspect: false, isDelivery: false, isBilling: false })).toBe(true)
expect(canSelectProspect({ isProspect: false, isDelivery: true, isBilling: false })).toBe(false)
expect(canSelectProspect({ isProspect: false, isDelivery: false, isBilling: true })).toBe(false)
expect(canSelectProspect(flags())).toBe(true)
expect(canSelectProspect(flags({ isDelivery: true }))).toBe(false)
expect(canSelectProspect(flags({ isBilling: true }))).toBe(false)
})
it('Livraison / Facturation selectionnables tant que pas Prospect', () => {
expect(canSelectDeliveryOrBilling({ isProspect: false, isDelivery: false, isBilling: false })).toBe(true)
expect(canSelectDeliveryOrBilling({ isProspect: true, isDelivery: false, isBilling: false })).toBe(false)
expect(canSelectDeliveryOrBilling(flags())).toBe(true)
expect(canSelectDeliveryOrBilling(flags({ isProspect: true }))).toBe(false)
})
it('cocher Prospect efface Livraison et Facturation', () => {
const next = applyProspectExclusivity(
{ isProspect: false, isDelivery: true, isBilling: true },
'isProspect',
true,
)
expect(next).toEqual({ isProspect: true, isDelivery: false, isBilling: false })
const next = applyProspectExclusivity(flags({ isDelivery: true, isBilling: true }), 'isProspect', true)
expect(next).toEqual(flags({ isProspect: true }))
})
it('cocher Livraison efface Prospect', () => {
const next = applyProspectExclusivity(
{ isProspect: true, isDelivery: false, isBilling: false },
'isDelivery',
true,
)
expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: false })
const next = applyProspectExclusivity(flags({ isProspect: true }), 'isDelivery', true)
expect(next).toEqual(flags({ isDelivery: true }))
})
it('cocher Facturation efface Prospect mais conserve Livraison', () => {
const next = applyProspectExclusivity(
{ isProspect: true, isDelivery: true, isBilling: false },
'isBilling',
true,
)
expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: true })
const next = applyProspectExclusivity(flags({ isProspect: true, isDelivery: true }), 'isBilling', true)
expect(next).toEqual(flags({ isDelivery: true, isBilling: true }))
})
it('decocher un drapeau ne reactive rien d autre', () => {
const next = applyProspectExclusivity(
{ isProspect: false, isDelivery: true, isBilling: true },
'isBilling',
false,
)
expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: false })
const next = applyProspectExclusivity(flags({ isDelivery: true, isBilling: true }), 'isBilling', false)
expect(next).toEqual(flags({ isDelivery: true }))
})
})
describe('isBillingEmailRequired (RG-1.11)', () => {
it('obligatoire uniquement si Facturation est coche', () => {
expect(isBillingEmailRequired({ isProspect: false, isDelivery: false, isBilling: true })).toBe(true)
expect(isBillingEmailRequired({ isProspect: false, isDelivery: true, isBilling: false })).toBe(false)
expect(isBillingEmailRequired(flags({ isBilling: true }))).toBe(true)
expect(isBillingEmailRequired(flags({ isDelivery: true }))).toBe(false)
})
})
describe('type d\'adresse (Select front) <-> drapeaux back', () => {
it('addressFlagsFromType mappe chaque type vers les bons drapeaux', () => {
expect(addressFlagsFromType('prospect')).toEqual({ isProspect: true, isDelivery: false, isBilling: false })
expect(addressFlagsFromType('delivery')).toEqual({ isProspect: false, isDelivery: true, isBilling: false })
expect(addressFlagsFromType('billing')).toEqual({ isProspect: false, isDelivery: false, isBilling: true })
expect(addressFlagsFromType('delivery_billing')).toEqual({ isProspect: false, isDelivery: true, isBilling: true })
expect(addressFlagsFromType('prospect')).toEqual(flags({ isProspect: true }))
expect(addressFlagsFromType('delivery')).toEqual(flags({ isDelivery: true }))
expect(addressFlagsFromType('billing')).toEqual(flags({ isBilling: true }))
expect(addressFlagsFromType('delivery_billing')).toEqual(flags({ isDelivery: true, isBilling: true }))
expect(addressFlagsFromType('broker')).toEqual(flags({ isBroker: true }))
expect(addressFlagsFromType('distributor')).toEqual(flags({ isDistributor: true }))
})
it('addressTypeFromFlags reconstruit le type (Prospect prioritaire, livraison+facturation groupes)', () => {
expect(addressTypeFromFlags({ isProspect: true, isDelivery: false, isBilling: false })).toBe('prospect')
expect(addressTypeFromFlags({ isProspect: false, isDelivery: true, isBilling: false })).toBe('delivery')
expect(addressTypeFromFlags({ isProspect: false, isDelivery: false, isBilling: true })).toBe('billing')
expect(addressTypeFromFlags({ isProspect: false, isDelivery: true, isBilling: true })).toBe('delivery_billing')
it('addressTypeFromFlags reconstruit le type (Prospect/Courtier/Distributeur autonomes, livraison+facturation groupes)', () => {
expect(addressTypeFromFlags(flags({ isProspect: true }))).toBe('prospect')
expect(addressTypeFromFlags(flags({ isDelivery: true }))).toBe('delivery')
expect(addressTypeFromFlags(flags({ isBilling: true }))).toBe('billing')
expect(addressTypeFromFlags(flags({ isDelivery: true, isBilling: true }))).toBe('delivery_billing')
expect(addressTypeFromFlags(flags({ isBroker: true }))).toBe('broker')
expect(addressTypeFromFlags(flags({ isDistributor: true }))).toBe('distributor')
})
it('addressTypeFromFlags retourne null quand aucun drapeau (amorce vierge -> bouton bloque)', () => {
expect(addressTypeFromFlags({ isProspect: false, isDelivery: false, isBilling: false })).toBeNull()
expect(addressTypeFromFlags(flags())).toBeNull()
})
it('aller-retour type -> drapeaux -> type stable pour les 4 types', () => {
for (const type of ['prospect', 'delivery', 'billing', 'delivery_billing'] as const) {
it('aller-retour type -> drapeaux -> type stable pour les 6 types', () => {
for (const type of ['prospect', 'delivery', 'billing', 'delivery_billing', 'broker', 'distributor'] as const) {
expect(addressTypeFromFlags(addressFlagsFromType(type))).toBe(type)
}
})
@@ -325,6 +322,8 @@ describe('isAddressValid (gating « + Adresse » + validation onglet)', () => {
isProspect: false,
isDelivery: true,
isBilling: false,
isBroker: false,
isDistributor: false,
categoryIris: ['/api/client_categories/1'],
siteIris: ['/api/sites/1'],
billingEmail: null,
@@ -66,6 +66,8 @@ export interface AddressRead extends HydraRef {
isProspect?: boolean
isDelivery?: boolean
isBilling?: boolean
isBroker?: boolean
isDistributor?: boolean
sites?: SiteRead[]
categories?: CategoryRead[]
// L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu.
@@ -209,6 +211,8 @@ export function mapAddressToDraft(address: AddressRead): AddressFormDraft {
isProspect: address.isProspect ?? false,
isDelivery: address.isDelivery ?? false,
isBilling: address.isBilling ?? false,
isBroker: address.isBroker ?? false,
isDistributor: address.isDistributor ?? false,
country: address.country ?? 'France',
postalCode: address.postalCode ?? null,
city: address.city ?? null,
@@ -210,6 +210,8 @@ export function buildAddressPayload(
isProspect: address.isProspect,
isDelivery: address.isDelivery,
isBilling: address.isBilling,
isBroker: address.isBroker,
isDistributor: address.isDistributor,
country: address.country,
postalCode: address.postalCode || null,
city: address.city || null,
@@ -81,6 +81,10 @@ export interface AddressFlagsDraft {
isProspect: boolean
isDelivery: boolean
isBilling: boolean
/** Adresse Courtier — type autonome exclusif (comme isProspect). */
isBroker: boolean
/** Adresse Distributeur — type autonome exclusif (comme isProspect). */
isDistributor: boolean
}
/** Vrai si une chaine porte au moins un caractere non-espace. */
@@ -220,22 +224,30 @@ export function isBillingEmailRequired(flags: AddressFlagsDraft): boolean {
* drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). Les seules
* combinaisons proposees respectent l'exclusivite Prospect (RG-1.06/07/08).
*/
export type AddressType = 'prospect' | 'delivery' | 'billing' | 'delivery_billing'
export type AddressType = 'prospect' | 'delivery' | 'billing' | 'delivery_billing' | 'broker' | 'distributor'
/**
* Mappe le type d'adresse choisi vers les trois drapeaux back.
* Mappe le type d'adresse choisi vers les cinq drapeaux back.
* « Adresse + Facturation » = livraison ET facturation sur la meme adresse.
* Courtier / Distributeur sont autonomes (un seul drapeau, exclusif du reste).
*/
export function addressFlagsFromType(type: AddressType): AddressFlagsDraft {
const none: AddressFlagsDraft = {
isProspect: false, isDelivery: false, isBilling: false, isBroker: false, isDistributor: false,
}
switch (type) {
case 'prospect':
return { isProspect: true, isDelivery: false, isBilling: false }
return { ...none, isProspect: true }
case 'delivery':
return { isProspect: false, isDelivery: true, isBilling: false }
return { ...none, isDelivery: true }
case 'billing':
return { isProspect: false, isDelivery: false, isBilling: true }
return { ...none, isBilling: true }
case 'delivery_billing':
return { isProspect: false, isDelivery: true, isBilling: true }
return { ...none, isDelivery: true, isBilling: true }
case 'broker':
return { ...none, isBroker: true }
case 'distributor':
return { ...none, isDistributor: true }
}
}
@@ -246,6 +258,8 @@ export function addressFlagsFromType(type: AddressType): AddressFlagsDraft {
*/
export function addressTypeFromFlags(flags: AddressFlagsDraft): AddressType | null {
if (flags.isProspect) return 'prospect'
if (flags.isBroker) return 'broker'
if (flags.isDistributor) return 'distributor'
if (flags.isDelivery && flags.isBilling) return 'delivery_billing'
if (flags.isDelivery) return 'delivery'
if (flags.isBilling) return 'billing'