diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 4df66ab..bac08d0 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -186,6 +186,8 @@ "sites": "Sites", "contacts": "Contact(s) rattaché(s)", "billingEmail": "Email de facturation", + "billingEmailSecondary": "Email de facturation secondaire", + "addBillingEmail": "Ajouter un email", "remove": "Supprimer l'adresse", "add": "Nouvelle adresse", "degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre." diff --git a/frontend/modules/commercial/components/ClientAddressBlock.vue b/frontend/modules/commercial/components/ClientAddressBlock.vue index 1cc792b..3533a7d 100644 --- a/frontend/modules/commercial/components/ClientAddressBlock.vue +++ b/frontend/modules/commercial/components/ClientAddressBlock.vue @@ -47,9 +47,10 @@ @update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))" /> - + -
+
(field: K, value: AddressFormDr emit('update:modelValue', { ...props.modelValue, [field]: value }) } +/** Revele le 2e champ email de facturation (clic sur le « + »). */ +function revealSecondaryBillingEmail(): void { + emit('update:modelValue', { ...props.modelValue, hasSecondaryBillingEmail: true }) +} + /** Previent le parent (toast unique) que l'autocompletion est indisponible. */ function notifyUnavailable(): void { if (!unavailableNotified) { diff --git a/frontend/modules/commercial/pages/clients/new.vue b/frontend/modules/commercial/pages/clients/new.vue index 6c19208..1d8ca7a 100644 --- a/frontend/modules/commercial/pages/clients/new.vue +++ b/frontend/modules/commercial/pages/clients/new.vue @@ -841,6 +841,7 @@ async function submitAddresses(): Promise { sites: address.siteIris, contacts: address.contactIris, billingEmail: isBillingEmailRequired(address) ? (address.billingEmail || null) : null, + billingEmailSecondary: isBillingEmailRequired(address) && address.hasSecondaryBillingEmail ? (address.billingEmailSecondary || null) : null, }, ADDRESS_REQUIRED_NON_NULLABLE_KEYS) if (address.id === null) { const created = await api.post<{ id: number }>( diff --git a/frontend/modules/commercial/types/clientForm.ts b/frontend/modules/commercial/types/clientForm.ts index d8a9b0a..9313bf3 100644 --- a/frontend/modules/commercial/types/clientForm.ts +++ b/frontend/modules/commercial/types/clientForm.ts @@ -47,6 +47,10 @@ export interface AddressFormDraft { contactIris: string[] /** Email de facturation (obligatoire si isBilling — RG-1.11). */ billingEmail: string | null + /** 2e email de facturation, optionnel (max 2 — pendant du telephone secondaire). */ + billingEmailSecondary: string | null + /** Drapeau UI : 2e champ email revele (comme hasSecondaryPhone). */ + hasSecondaryBillingEmail: boolean } /** Un RIB du client (onglet Comptabilite). */ @@ -90,6 +94,8 @@ export function emptyAddress(): AddressFormDraft { siteIris: [], contactIris: [], billingEmail: null, + billingEmailSecondary: null, + hasSecondaryBillingEmail: false, } } diff --git a/frontend/modules/commercial/utils/__tests__/clientEdit.spec.ts b/frontend/modules/commercial/utils/__tests__/clientEdit.spec.ts index f550d36..c586f9e 100644 --- a/frontend/modules/commercial/utils/__tests__/clientEdit.spec.ts +++ b/frontend/modules/commercial/utils/__tests__/clientEdit.spec.ts @@ -160,10 +160,14 @@ describe('buildContactPayload / buildAddressPayload / buildRibPayload', () => { 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', + billingEmail: 'facturation@acme.fr', billingEmailSecondary: 'compta@acme.fr', hasSecondaryBillingEmail: true, } expect(buildAddressPayload(address, true).billingEmail).toBe('facturation@acme.fr') expect(buildAddressPayload(address, false).billingEmail).toBeNull() + // 2e email : transmis si facturation + revele, sinon null (ERP-119). + expect(buildAddressPayload(address, true).billingEmailSecondary).toBe('compta@acme.fr') + expect(buildAddressPayload(address, false).billingEmailSecondary).toBeNull() + expect(buildAddressPayload({ ...address, hasSecondaryBillingEmail: false }, true).billingEmailSecondary).toBeNull() }) it('rib : label / bic / iban transmis tels quels', () => { @@ -187,7 +191,7 @@ describe('buildContactPayload / buildAddressPayload / buildRibPayload', () => { 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, + billingEmail: null, billingEmailSecondary: null, hasSecondaryBillingEmail: false, } const payload = buildAddressPayload(address, false) expect('postalCode' in payload).toBe(false) diff --git a/frontend/modules/commercial/utils/clientConsultation.ts b/frontend/modules/commercial/utils/clientConsultation.ts index 1b95dac..ae0366a 100644 --- a/frontend/modules/commercial/utils/clientConsultation.ts +++ b/frontend/modules/commercial/utils/clientConsultation.ts @@ -63,6 +63,7 @@ export interface AddressRead extends HydraRef { street?: string | null streetComplement?: string | null billingEmail?: string | null + billingEmailSecondary?: string | null isProspect?: boolean isDelivery?: boolean isBilling?: boolean @@ -222,6 +223,8 @@ export function mapAddressToDraft(address: AddressRead): AddressFormDraft { siteIris: (address.sites ?? []).map(s => s['@id']), contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])), billingEmail: address.billingEmail ?? null, + billingEmailSecondary: address.billingEmailSecondary ?? null, + hasSecondaryBillingEmail: (address.billingEmailSecondary ?? null) !== null && address.billingEmailSecondary !== '', } } diff --git a/frontend/modules/commercial/utils/clientEdit.ts b/frontend/modules/commercial/utils/clientEdit.ts index b5272cb..2b9fb0d 100644 --- a/frontend/modules/commercial/utils/clientEdit.ts +++ b/frontend/modules/commercial/utils/clientEdit.ts @@ -221,6 +221,7 @@ export function buildAddressPayload( sites: address.siteIris, contacts: address.contactIris, billingEmail: isBillingEmailRequired ? (address.billingEmail || null) : null, + billingEmailSecondary: isBillingEmailRequired && address.hasSecondaryBillingEmail ? (address.billingEmailSecondary || null) : null, }, ADDRESS_REQUIRED_NON_NULLABLE_KEYS) } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 211699f..43f4fc0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,7 +7,7 @@ "name": "starseed-frontend", "hasInstallScript": true, "dependencies": { - "@malio/layer-ui": "^1.7.7", + "@malio/layer-ui": "^1.7.8", "@nuxt/icon": "^2.2.1", "@nuxtjs/i18n": "^10.2.3", "@nuxtjs/tailwindcss": "^6.14.0", @@ -1866,9 +1866,9 @@ "license": "MIT" }, "node_modules/@malio/layer-ui": { - "version": "1.7.7", - "resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.7/layer-ui-1.7.7.tgz", - "integrity": "sha512-MLHDtOzUxcCwIBGWj4FcUMLQTExtGD29uLvpU+IA6qr7gCj9kZ9fGZDu76LXxuJJdfBwzZmenuZioE7Z1qQUUw==", + "version": "1.7.8", + "resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.8/layer-ui-1.7.8.tgz", + "integrity": "sha512-gUMAZzBsPCfQUF3OQSjN/OFzjONvQZYfwqH0u5VUbxaqwBdX1hUGtjD4ym6RvZkyNsKulrxkncFZYTWCS+IdGA==", "dependencies": { "@nuxt/icon": "^2.2.1", "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/frontend/package.json b/frontend/package.json index 2e1e916..a60e49b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,7 +17,7 @@ "test:e2e:ui": "playwright test --ui" }, "dependencies": { - "@malio/layer-ui": "^1.7.7", + "@malio/layer-ui": "^1.7.8", "@nuxt/icon": "^2.2.1", "@nuxtjs/i18n": "^10.2.3", "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/migrations/Version20260609140000.php b/migrations/Version20260609140000.php new file mode 100644 index 0000000..cc04c66 --- /dev/null +++ b/migrations/Version20260609140000.php @@ -0,0 +1,51 @@ +addSql('ALTER TABLE client_address ADD COLUMN billing_email_secondary VARCHAR(180) DEFAULT NULL'); + + $this->comment('client_address', 'billing_email_secondary', '2e email de facturation, optionnel (max 2). Interdit hors facturation (validateBillingEmailPresence), normalise en minuscules (RG-1.21).'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE client_address DROP COLUMN billing_email_secondary'); + } + + /** + * 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 b1e543c..98d0a45 100644 --- a/src/Module/Commercial/Domain/Entity/ClientAddress.php +++ b/src/Module/Commercial/Domain/Entity/ClientAddress.php @@ -178,6 +178,15 @@ class ClientAddress implements TimestampableInterface, BlamableInterface #[Groups(['client_address:read', 'client_address:write'])] private ?string $billingEmail = null; + // 2e email de facturation, optionnel (max 2 — pendant du telephone secondaire). + // Comme le principal : interdit hors facturation (validateBillingEmailPresence), + // mais jamais obligatoire. Normalise en lowercase par le ClientAddressProcessor. + #[ORM\Column(length: 180, nullable: true)] + #[Assert\Email(message: 'L\'email de facturation secondaire n\'est pas valide.')] + #[Assert\Length(max: 180, maxMessage: 'L\'email de facturation secondaire ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] + #[Groups(['client_address:read', 'client_address:write'])] + private ?string $billingEmailSecondary = null; + #[ORM\Column(options: ['default' => 0])] #[Groups(['client_address:read', 'client_address:write'])] private int $position = 0; @@ -308,6 +317,16 @@ class ClientAddress implements TimestampableInterface, BlamableInterface ->addViolation() ; } + + // Le 2e email est OPTIONNEL (jamais requis), mais comme le principal il + // n'a de sens que sur une adresse de facturation. + $hasSecondaryEmail = null !== $this->billingEmailSecondary && '' !== trim($this->billingEmailSecondary); + if (!$this->isBilling && $hasSecondaryEmail) { + $context->buildViolation('L\'email de facturation n\'est autorisé que sur une adresse de facturation.') + ->atPath('billingEmailSecondary') + ->addViolation() + ; + } } /** @@ -497,6 +516,18 @@ class ClientAddress implements TimestampableInterface, BlamableInterface return $this; } + public function getBillingEmailSecondary(): ?string + { + return $this->billingEmailSecondary; + } + + public function setBillingEmailSecondary(?string $billingEmailSecondary): static + { + $this->billingEmailSecondary = $billingEmailSecondary; + + return $this; + } + public function getPosition(): int { return $this->position; diff --git a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientAddressProcessor.php b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientAddressProcessor.php index 01249e9..6fc350d 100644 --- a/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientAddressProcessor.php +++ b/src/Module/Commercial/Infrastructure/ApiPlatform/State/Processor/ClientAddressProcessor.php @@ -94,5 +94,6 @@ final class ClientAddressProcessor implements ProcessorInterface private function normalize(ClientAddress $address): void { $address->setBillingEmail($this->normalizer->normalizeEmail($address->getBillingEmail())); + $address->setBillingEmailSecondary($this->normalizer->normalizeEmail($address->getBillingEmailSecondary())); } } diff --git a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php index 0a749f3..3723259 100644 --- a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php +++ b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php @@ -219,21 +219,22 @@ final class ColumnCommentsCatalog ] + self::timestampableBlamableComments(), 'client_address' => [ - '_table' => 'Adresses d un client (1:n) — prospect exclusif de livraison/facturation (RG-1.06/07/08), >= 1 site rattache (RG-1.10).', - 'id' => 'Identifiant interne auto-incremente.', - 'client_id' => 'FK -> client.id, ON DELETE CASCADE — client proprietaire de l adresse.', - '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).', - 'street' => 'Numero et voie de l adresse.', - 'street_complement' => 'Complement d adresse (etage, batiment...) — optionnel.', - 'billing_email' => 'Email de facturation — obligatoire si is_billing, null sinon (RG-1.11, chk_client_address_billing_email).', - 'position' => 'Ordre d affichage de l adresse dans la liste du client (croissant).', + '_table' => 'Adresses d un client (1:n) — prospect exclusif de livraison/facturation (RG-1.06/07/08), >= 1 site rattache (RG-1.10).', + 'id' => 'Identifiant interne auto-incremente.', + 'client_id' => 'FK -> client.id, ON DELETE CASCADE — client proprietaire de l adresse.', + '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).', + 'street' => 'Numero et voie de l adresse.', + 'street_complement' => 'Complement d adresse (etage, batiment...) — optionnel.', + 'billing_email' => 'Email de facturation — obligatoire si is_billing, null sinon (RG-1.11, chk_client_address_billing_email).', + 'billing_email_secondary' => '2e email de facturation, optionnel (max 2). Interdit hors facturation, normalise en minuscules (RG-1.21).', + 'position' => 'Ordre d affichage de l adresse dans la liste du client (croissant).', ] + self::timestampableBlamableComments(), 'client_address_site' => [ diff --git a/tests/Module/Commercial/Api/ClientAddressTest.php b/tests/Module/Commercial/Api/ClientAddressTest.php index 9073a4f..2cadca4 100644 --- a/tests/Module/Commercial/Api/ClientAddressTest.php +++ b/tests/Module/Commercial/Api/ClientAddressTest.php @@ -189,6 +189,65 @@ final class ClientAddressTest extends AbstractCommercialApiTestCase self::assertResponseStatusCodeSame(201); } + /** + * ERP-119 : une adresse de facturation accepte un 2e email (optionnel, max 2). + */ + public function testBillingAddressAcceptsTwoEmails(): void + { + $this->skipIfSitesModuleDisabled(); + $client = $this->createAdminClient(); + $seed = $this->seedClient('Billing Two Emails'); + $category = $this->createCategory('SECTEUR'); + + $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'isBilling' => true, + 'billingEmail' => 'facturation@test.fr', + 'billingEmailSecondary' => 'compta@test.fr', + 'postalCode' => '86100', + 'city' => 'Châtellerault', + 'street' => '1 rue du Test', + 'sites' => [$this->firstSiteIri()], + 'categories' => ['/api/categories/'.$category->getId()], + ], + ]); + + self::assertResponseStatusCodeSame(201); + } + + /** + * ERP-119 : le 2e email de facturation, comme le principal, n'est autorise que + * sur une adresse de facturation -> 422 avec violation sur billingEmailSecondary. + */ + public function testSecondaryBillingEmailRejectedOnNonBillingAddress(): void + { + $this->skipIfSitesModuleDisabled(); + $client = $this->createAdminClient(); + $seed = $this->seedClient('Secondary Email Non Billing'); + $category = $this->createCategory('SECTEUR'); + + $body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], + 'json' => [ + 'isDelivery' => true, + 'billingEmailSecondary' => 'compta@test.fr', + '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('billingEmailSecondary', $byPath); + } + /** * RG-1.29 : poster une categorie de type DISTRIBUTEUR sur une adresse -> 422 * avec violation sur le champ `categories`.