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:
@@ -173,6 +173,8 @@
|
||||
"addressTypeDelivery": "Livraison",
|
||||
"addressTypeBilling": "Facturation",
|
||||
"addressTypeDeliveryBilling": "Adresse + Facturation",
|
||||
"addressTypeBroker": "Adresse Courtier",
|
||||
"addressTypeDistributor": "Adresse Distributeur",
|
||||
"categories": "Catégorie",
|
||||
"country": "Pays",
|
||||
"postalCode": "Code postal",
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Commercial — deux nouveaux types d'adresse client : Courtier et Distributeur.
|
||||
*
|
||||
* Ajoute les drapeaux `is_broker` / `is_distributor` sur `client_address`, au
|
||||
* meme titre que `is_prospect` / `is_delivery` / `is_billing`. Ce sont des types
|
||||
* AUTONOMES (comme la Prospection) : exclusifs de tout autre usage. Deux CHECK
|
||||
* Postgres miroitent l'exclusivite applicative (validateExclusiveAddressTypes),
|
||||
* en filet de securite (comme chk_client_address_prospect_exclusive).
|
||||
*
|
||||
* NB Postgres : `ADD COLUMN` ajoute en derniere position physique (pas de clause
|
||||
* AFTER) — l'ordre physique est cosmetique, on adresse par nom. Les colonnes sont
|
||||
* declarees juste apres isBilling dans l'entite (ERP-119).
|
||||
*
|
||||
* Migration au namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) : le
|
||||
* tri par version garantit son passage apres l'init des tables.
|
||||
*/
|
||||
final class Version20260609120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Commercial : types d\'adresse Courtier / Distributeur (is_broker / is_distributor) sur client_address, exclusifs (CHECK).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE client_address ADD COLUMN is_broker BOOLEAN DEFAULT FALSE NOT NULL');
|
||||
$this->addSql('ALTER TABLE client_address ADD COLUMN is_distributor BOOLEAN DEFAULT FALSE NOT NULL');
|
||||
|
||||
// Exclusivite miroir (filet de securite DBAL) : un type autonome interdit
|
||||
// tout autre drapeau. Livraison + Facturation restent cumulables entre eux.
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE client_address
|
||||
ADD CONSTRAINT chk_client_address_broker_exclusive
|
||||
CHECK (NOT (is_broker = TRUE AND (is_prospect = TRUE OR is_delivery = TRUE OR is_billing = TRUE OR is_distributor = TRUE)))
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE client_address
|
||||
ADD CONSTRAINT chk_client_address_distributor_exclusive
|
||||
CHECK (NOT (is_distributor = TRUE AND (is_prospect = TRUE OR is_delivery = TRUE OR is_billing = TRUE OR is_broker = TRUE)))
|
||||
SQL);
|
||||
|
||||
$this->comment('client_address', 'is_broker', 'Adresse Courtier — type autonome exclusif de tout autre usage (chk_client_address_broker_exclusive). Faux par defaut.');
|
||||
$this->comment('client_address', 'is_distributor', 'Adresse Distributeur — type autonome exclusif de tout autre usage (chk_client_address_distributor_exclusive). Faux par defaut.');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE client_address DROP CONSTRAINT IF EXISTS chk_client_address_broker_exclusive');
|
||||
$this->addSql('ALTER TABLE client_address DROP CONSTRAINT IF EXISTS chk_client_address_distributor_exclusive');
|
||||
$this->addSql('ALTER TABLE client_address DROP COLUMN is_distributor');
|
||||
$this->addSql('ALTER TABLE client_address DROP COLUMN is_broker');
|
||||
}
|
||||
|
||||
/**
|
||||
* Emet un `COMMENT ON COLUMN` en dollar-quoting Postgres ($_$...$_$) pour
|
||||
* eviter tout echappement.
|
||||
*/
|
||||
private function comment(string $table, string $column, string $description): void
|
||||
{
|
||||
$this->addSql(sprintf(
|
||||
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
||||
'"'.str_replace('"', '""', $table).'"',
|
||||
'"'.str_replace('"', '""', $column).'"',
|
||||
$description,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,18 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
|
||||
#[Groups(['client_address:write'])]
|
||||
private bool $isBilling = false;
|
||||
|
||||
// Adresse Courtier / Distributeur : types autonomes (comme Prospection),
|
||||
// exclusifs de tout autre usage (validateExclusiveAddressTypes + CHECK BDD
|
||||
// chk_client_address_broker_exclusive / chk_client_address_distributor_exclusive).
|
||||
// Lecture portee par le getter + SerializedName (meme pattern que isProspect).
|
||||
#[ORM\Column(name: 'is_broker', options: ['default' => false])]
|
||||
#[Groups(['client_address:write'])]
|
||||
private bool $isBroker = false;
|
||||
|
||||
#[ORM\Column(name: 'is_distributor', options: ['default' => false])]
|
||||
#[Groups(['client_address:write'])]
|
||||
private bool $isDistributor = false;
|
||||
|
||||
#[ORM\Column(length: 80, options: ['default' => 'France'])]
|
||||
#[Assert\Length(max: 80, maxMessage: 'Le pays ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
@@ -232,7 +244,7 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
|
||||
#[Assert\Callback]
|
||||
public function validateAddressTypeRequired(ExecutionContextInterface $context): void
|
||||
{
|
||||
if (!$this->isProspect && !$this->isDelivery && !$this->isBilling) {
|
||||
if (!$this->isProspect && !$this->isDelivery && !$this->isBilling && !$this->isBroker && !$this->isDistributor) {
|
||||
$context->buildViolation('Le type d\'adresse est obligatoire.')
|
||||
->atPath('isProspect')
|
||||
->addViolation()
|
||||
@@ -240,6 +252,31 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Courtier et Distributeur sont des types d'adresse AUTONOMES (comme la
|
||||
* Prospection) : exclusifs de tout autre usage (Livraison / Facturation /
|
||||
* Prospection / l'autre type autonome). Mirror applicatif (422) des CHECK
|
||||
* chk_client_address_broker_exclusive / chk_client_address_distributor_exclusive.
|
||||
* Violation portee sur `isProspect` (mappee sous le select « Type d'adresse »).
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validateExclusiveAddressTypes(ExecutionContextInterface $context): void
|
||||
{
|
||||
if ($this->isBroker && ($this->isProspect || $this->isDelivery || $this->isBilling || $this->isDistributor)) {
|
||||
$context->buildViolation('Une adresse Courtier ne peut pas avoir d\'autre type.')
|
||||
->atPath('isProspect')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
|
||||
if ($this->isDistributor && ($this->isProspect || $this->isDelivery || $this->isBilling || $this->isBroker)) {
|
||||
$context->buildViolation('Une adresse Distributeur ne peut pas avoir d\'autre type.')
|
||||
->atPath('isProspect')
|
||||
->addViolation()
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-1.11 : l'email de facturation est obligatoire si l'adresse est de
|
||||
* facturation, et interdit sinon. Mirror applicatif (422) du CHECK
|
||||
@@ -360,6 +397,34 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[Groups(['client_address:read'])]
|
||||
#[SerializedName('isBroker')]
|
||||
public function isBroker(): bool
|
||||
{
|
||||
return $this->isBroker;
|
||||
}
|
||||
|
||||
public function setIsBroker(bool $isBroker): static
|
||||
{
|
||||
$this->isBroker = $isBroker;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[Groups(['client_address:read'])]
|
||||
#[SerializedName('isDistributor')]
|
||||
public function isDistributor(): bool
|
||||
{
|
||||
return $this->isDistributor;
|
||||
}
|
||||
|
||||
public function setIsDistributor(bool $isDistributor): static
|
||||
{
|
||||
$this->isDistributor = $isDistributor;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCountry(): string
|
||||
{
|
||||
return $this->country;
|
||||
|
||||
@@ -225,6 +225,8 @@ final class ColumnCommentsCatalog
|
||||
'is_prospect' => 'Adresse de prospection — exclusive de is_delivery/is_billing (RG-1.06/07/08, chk_client_address_prospect_exclusive). Faux par defaut.',
|
||||
'is_delivery' => 'Adresse de livraison. Exclusive de is_prospect. Faux par defaut.',
|
||||
'is_billing' => 'Adresse de facturation. Exclusive de is_prospect. Impose billing_email (RG-1.11). Faux par defaut.',
|
||||
'is_broker' => 'Adresse Courtier — type autonome exclusif de tout autre usage (chk_client_address_broker_exclusive). Faux par defaut.',
|
||||
'is_distributor' => 'Adresse Distributeur — type autonome exclusif de tout autre usage (chk_client_address_distributor_exclusive). Faux par defaut.',
|
||||
'country' => 'Pays de l adresse — defaut France.',
|
||||
'postal_code' => 'Code postal (4-5 chiffres attendus, RG-1.09).',
|
||||
'city' => 'Ville — preremplie depuis le code postal via API BAN cote front (RG-1.09).',
|
||||
|
||||
@@ -351,6 +351,88 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase
|
||||
self::assertSame('Le type d\'adresse est obligatoire.', $byPath['isProspect']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nouveaux types d'adresse (ERP-119) : Courtier et Distributeur sont acceptes
|
||||
* comme types autonomes (avec site + categorie). is_broker / is_distributor.
|
||||
*/
|
||||
public function testBrokerAddressAccepted(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Address Broker Type');
|
||||
$category = $this->createCategory('SECTEUR');
|
||||
|
||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'isBroker' => true,
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [$this->firstSiteIri()],
|
||||
'categories' => ['/api/categories/'.$category->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
public function testDistributorAddressAccepted(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Address Distributor Type');
|
||||
$category = $this->createCategory('SECTEUR');
|
||||
|
||||
$client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'isDistributor' => true,
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [$this->firstSiteIri()],
|
||||
'categories' => ['/api/categories/'.$category->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Courtier / Distributeur sont des types AUTONOMES exclusifs : les combiner avec
|
||||
* un autre usage (ici Livraison) -> 422, violation sur isProspect (mappee sous le
|
||||
* select Type d'adresse). Miroir applicatif du CHECK chk_client_address_broker_exclusive.
|
||||
*/
|
||||
public function testExclusiveAddressTypeRejected(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedClient('Address Broker Mix');
|
||||
$category = $this->createCategory('SECTEUR');
|
||||
|
||||
$body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
'json' => [
|
||||
'isBroker' => true,
|
||||
'isDelivery' => true,
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [$this->firstSiteIri()],
|
||||
'categories' => ['/api/categories/'.$category->getId()],
|
||||
],
|
||||
])->toArray(false);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
$byPath = [];
|
||||
foreach ($body['violations'] ?? [] as $v) {
|
||||
$byPath[$v['propertyPath']] = $v['message'];
|
||||
}
|
||||
self::assertArrayHasKey('isProspect', $byPath);
|
||||
self::assertSame('Une adresse Courtier ne peut pas avoir d\'autre type.', $byPath['isProspect']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne l'IRI du premier site seede (fixtures Sites).
|
||||
*/
|
||||
|
||||
@@ -59,12 +59,18 @@ final class ClientSerializationContractTest extends AbstractCommercialApiTestCas
|
||||
self::assertArrayHasKey('isProspect', $address);
|
||||
self::assertArrayHasKey('isDelivery', $address);
|
||||
self::assertArrayHasKey('isBilling', $address);
|
||||
// Memes garanties pour les types Courtier / Distributeur (ERP-119, meme
|
||||
// pattern getter + SerializedName).
|
||||
self::assertArrayHasKey('isBroker', $address);
|
||||
self::assertArrayHasKey('isDistributor', $address);
|
||||
|
||||
// L'adresse seedee est livraison + facturation (prospect exclusif, RG-1.06).
|
||||
// Prouve qu'un booleen `true` est bien serialise (le bug masquait meme les true).
|
||||
self::assertFalse($address['isProspect']);
|
||||
self::assertTrue($address['isDelivery']);
|
||||
self::assertTrue($address['isBilling']);
|
||||
self::assertFalse($address['isBroker']);
|
||||
self::assertFalse($address['isDistributor']);
|
||||
}
|
||||
|
||||
// === #80 — Gating des RIB par accounting.view ===
|
||||
|
||||
Reference in New Issue
Block a user