feat(commercial) : 2e email de facturation optionnel sur l'adresse client (ERP-119)
Pendant du telephone secondaire (max 2). Bump @malio/layer-ui 1.7.8 (prop addable du MalioInputEmail). - back : colonne billing_email_secondary (migration + COMMENT + catalogue), propriete ClientAddress (Email + Length), Callback etendu (2e email interdit hors facturation, optionnel sinon), normalisation lowercase au Processor. - front : draft + flag UI hasSecondaryBillingEmail, mappers, payloads, champ MalioInputEmail :addable -> revele un 2e champ ; layout : 2e email qui coule dans la grille et Adresse complementaire sur une colonne. - tests back (2 emails / 2e email hors facturation) et front (payload).
This commit is contained in:
@@ -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."
|
||||
|
||||
@@ -47,9 +47,10 @@
|
||||
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Email de facturation : ligne 1 colonne 4, visible/obligatoire
|
||||
seulement si Facturation (RG-1.11). Sinon un filler comble la
|
||||
colonne pour que Categorie reparte au debut de la ligne 2. -->
|
||||
<!-- Email(s) de facturation : visible/obligatoire seulement si Facturation
|
||||
(RG-1.11). Le « + » revele un 2e email optionnel (max 2, pendant du
|
||||
telephone secondaire) qui coule dans la grille. Sinon un filler comble
|
||||
la colonne pour que Categorie reparte au debut de la ligne suivante. -->
|
||||
<MalioInputEmail
|
||||
v-if="isBillingEmailRequired(model)"
|
||||
:model-value="model.billingEmail"
|
||||
@@ -58,10 +59,23 @@
|
||||
:readonly="readonly"
|
||||
:lowercase="true"
|
||||
:error="errors?.billingEmail"
|
||||
:addable="!model.hasSecondaryBillingEmail && !readonly"
|
||||
:add-button-label="t('commercial.clients.form.address.addBillingEmail')"
|
||||
@update:model-value="(v: string) => update('billingEmail', v)"
|
||||
@add="revealSecondaryBillingEmail"
|
||||
/>
|
||||
<div v-else aria-hidden="true" />
|
||||
|
||||
<MalioInputEmail
|
||||
v-if="isBillingEmailRequired(model) && model.hasSecondaryBillingEmail"
|
||||
:model-value="model.billingEmailSecondary"
|
||||
:label="t('commercial.clients.form.address.billingEmailSecondary')"
|
||||
:readonly="readonly"
|
||||
:lowercase="true"
|
||||
:error="errors?.billingEmailSecondary"
|
||||
@update:model-value="(v: string) => update('billingEmailSecondary', v)"
|
||||
/>
|
||||
|
||||
<MalioSelectCheckbox
|
||||
:model-value="model.categoryIris"
|
||||
:options="categoryOptions"
|
||||
@@ -154,7 +168,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2">
|
||||
<div class="col-span-1">
|
||||
<MalioInputText
|
||||
:model-value="model.streetComplement"
|
||||
:label="t('commercial.clients.form.address.streetComplement')"
|
||||
@@ -275,6 +289,11 @@ function update<K extends keyof AddressFormDraft>(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) {
|
||||
|
||||
@@ -841,6 +841,7 @@ async function submitAddresses(): Promise<void> {
|
||||
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 }>(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 !== '',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Generated
+4
-4
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Commercial — second email de facturation (optionnel) sur une adresse client.
|
||||
*
|
||||
* Ajoute `billing_email_secondary` sur `client_address`, pendant du telephone
|
||||
* secondaire du contact (max 2 emails). Optionnel ; comme l'email principal, il
|
||||
* n'a de sens que sur une adresse de facturation (validateBillingEmailPresence).
|
||||
*
|
||||
* Migration au namespace racine `DoctrineMigrations` (regle ABSOLUE n°11).
|
||||
*/
|
||||
final class Version20260609140000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Commercial : 2e email de facturation optionnel (billing_email_secondary) sur client_address.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+1
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user