diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 5560180..e8cbd0f 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -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", diff --git a/frontend/modules/commercial/components/ClientAddressBlock.vue b/frontend/modules/commercial/components/ClientAddressBlock.vue index bce7c9b..2fd55da 100644 --- a/frontend/modules/commercial/components/ClientAddressBlock.vue +++ b/frontend/modules/commercial/components/ClientAddressBlock.vue @@ -218,6 +218,8 @@ const addressTypeOptions = computed(() => [ { 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). */ diff --git a/frontend/modules/commercial/pages/clients/new.vue b/frontend/modules/commercial/pages/clients/new.vue index c50096a..a2c12e2 100644 --- a/frontend/modules/commercial/pages/clients/new.vue +++ b/frontend/modules/commercial/pages/clients/new.vue @@ -812,6 +812,8 @@ async function submitAddresses(): Promise { 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, diff --git a/frontend/modules/commercial/types/clientForm.ts b/frontend/modules/commercial/types/clientForm.ts index c24474d..d8a9b0a 100644 --- a/frontend/modules/commercial/types/clientForm.ts +++ b/frontend/modules/commercial/types/clientForm.ts @@ -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, diff --git a/frontend/modules/commercial/utils/__tests__/clientEdit.spec.ts b/frontend/modules/commercial/utils/__tests__/clientEdit.spec.ts index bbe4d8f..f550d36 100644 --- a/frontend/modules/commercial/utils/__tests__/clientEdit.spec.ts +++ b/frontend/modules/commercial/utils/__tests__/clientEdit.spec.ts @@ -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, diff --git a/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts b/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts index 30c677a..83fe6d4 100644 --- a/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts +++ b/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts @@ -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 { + 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, diff --git a/frontend/modules/commercial/utils/clientConsultation.ts b/frontend/modules/commercial/utils/clientConsultation.ts index 1b3761f..1b95dac 100644 --- a/frontend/modules/commercial/utils/clientConsultation.ts +++ b/frontend/modules/commercial/utils/clientConsultation.ts @@ -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, diff --git a/frontend/modules/commercial/utils/clientEdit.ts b/frontend/modules/commercial/utils/clientEdit.ts index 53e3290..b5272cb 100644 --- a/frontend/modules/commercial/utils/clientEdit.ts +++ b/frontend/modules/commercial/utils/clientEdit.ts @@ -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, diff --git a/frontend/modules/commercial/utils/clientFormRules.ts b/frontend/modules/commercial/utils/clientFormRules.ts index 9ab1bb8..35a8274 100644 --- a/frontend/modules/commercial/utils/clientFormRules.ts +++ b/frontend/modules/commercial/utils/clientFormRules.ts @@ -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' diff --git a/migrations/Version20260609120000.php b/migrations/Version20260609120000.php new file mode 100644 index 0000000..7c9eaf6 --- /dev/null +++ b/migrations/Version20260609120000.php @@ -0,0 +1,76 @@ +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, + )); + } +} diff --git a/src/Module/Commercial/Domain/Entity/ClientAddress.php b/src/Module/Commercial/Domain/Entity/ClientAddress.php index ab77987..b1e543c 100644 --- a/src/Module/Commercial/Domain/Entity/ClientAddress.php +++ b/src/Module/Commercial/Domain/Entity/ClientAddress.php @@ -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; diff --git a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php index 0c85547..0a749f3 100644 --- a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php +++ b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php @@ -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).', diff --git a/tests/Module/Commercial/Api/ClientAddressTest.php b/tests/Module/Commercial/Api/ClientAddressTest.php index 41a1b21..9073a4f 100644 --- a/tests/Module/Commercial/Api/ClientAddressTest.php +++ b/tests/Module/Commercial/Api/ClientAddressTest.php @@ -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). */ diff --git a/tests/Module/Commercial/Api/ClientSerializationContractTest.php b/tests/Module/Commercial/Api/ClientSerializationContractTest.php index 7a3e345..17410f8 100644 --- a/tests/Module/Commercial/Api/ClientSerializationContractTest.php +++ b/tests/Module/Commercial/Api/ClientSerializationContractTest.php @@ -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 ===