+
+
+
tabKeys.value.map((key, index) => ({
disabled: index > unlockedIndex.value,
})))
-// Onglets dont le contenu arrive aux tickets suivants (Contacts / Prix).
-const placeholderTabs = computed(() => tabKeys.value.filter(key => key !== 'qualimat' && key !== 'addresses'))
+// Onglets dont le contenu arrive aux tickets suivants (Prix).
+const placeholderTabs = computed(() => tabKeys.value.filter(
+ key => key !== 'qualimat' && key !== 'addresses' && key !== 'contacts',
+))
// ── Onglet Adresses (ERP-167) ────────────────────────────────────────────────
// Pays : France garantie en tete meme si /countries echoue (resilience), pour
@@ -469,7 +511,7 @@ async function onSubmitAddresses(): Promise {
}
}
-// Modal de confirmation de suppression (bloc adresse).
+// Modal de confirmation de suppression (générique : bloc adresse OU contact).
const deleteConfirm = reactive({ open: false, action: null as null | (() => void) })
function askRemoveAddress(index: number): void {
@@ -477,6 +519,22 @@ function askRemoveAddress(index: number): void {
deleteConfirm.open = true
}
+/** Valide l'onglet Contacts (POST/PATCH par ligne ; avance gérée par le composable). */
+async function onSubmitContacts(): Promise {
+ const ok = await submitContacts(error => toast.error({
+ title: t('transport.carriers.toast.error'),
+ message: apiErrorMessage(error),
+ }))
+ if (ok) {
+ toast.success({ title: t('transport.carriers.toast.contactSaved') })
+ }
+}
+
+function askRemoveContact(index: number): void {
+ deleteConfirm.action = () => { void removeContact(index) }
+ deleteConfirm.open = true
+}
+
function runDeleteConfirm(): void {
deleteConfirm.action?.()
deleteConfirm.action = null
diff --git a/frontend/modules/transport/types/carrierForm.ts b/frontend/modules/transport/types/carrierForm.ts
index f3d3596..ad7bb59 100644
--- a/frontend/modules/transport/types/carrierForm.ts
+++ b/frontend/modules/transport/types/carrierForm.ts
@@ -94,6 +94,41 @@ export function emptyCarrierAddress(): CarrierAddressFormDraft {
}
}
+/**
+ * Brouillon d'un bloc Contact (onglet Contacts, ERP-168) — sous-ressource
+ * `CarrierContact` (groupe `carrier:write:contacts`). Les téléphones sont saisis
+ * en `phonePrimary` / `phoneSecondary` côté UI, puis envoyés au back sous forme du
+ * tableau `phones` (max 2 — RG-4.08). `hasSecondaryPhone` pilote l'affichage du 2e
+ * numéro (révélé via le bouton « + »). Pas d'`iri` : aucune relation M2M depuis
+ * l'adresse au M4 (≠ M3).
+ */
+export interface CarrierContactFormDraft {
+ /** Id serveur une fois le contact créé (null tant que non persisté). */
+ id: number | null
+ firstName: string | null
+ lastName: string | null
+ jobTitle: string | null
+ phonePrimary: string | null
+ phoneSecondary: string | null
+ email: string | null
+ /** UI : le 2e numéro a été révélé via le bouton « + » (max 2 téléphones). */
+ hasSecondaryPhone: boolean
+}
+
+/** Brouillon de contact vide (état initial d'un bloc Contact). */
+export function emptyCarrierContact(): CarrierContactFormDraft {
+ return {
+ id: null,
+ firstName: null,
+ lastName: null,
+ jobTitle: null,
+ phonePrimary: null,
+ phoneSecondary: null,
+ email: null,
+ hasSecondaryPhone: false,
+ }
+}
+
/**
* Réponse du POST / PATCH principal (groupe `carrier:read`). Le serveur renvoie
* le nom normalisé (UPPERCASE, RG-4.13) que l'UI réaffiche tel quel.
diff --git a/frontend/modules/transport/utils/forms/carrierContact.ts b/frontend/modules/transport/utils/forms/carrierContact.ts
new file mode 100644
index 0000000..3d434f4
--- /dev/null
+++ b/frontend/modules/transport/utils/forms/carrierContact.ts
@@ -0,0 +1,61 @@
+/**
+ * Helpers purs de l'onglet Contact transporteur (M4 Transport, ERP-168) — ALIGNÉ
+ * sur `providerContact.ts` (M3) / les autres modules : mêmes règles de validité et
+ * de gating « + Nouveau contact » (un contact est « nommé » dès qu'il porte un
+ * prénom OU un nom). Seule spécificité M4 conservée : les téléphones partent au back
+ * dans le tableau virtuel `phones` (max 2), mappés par le CarrierContactProcessor.
+ * Testables sans Vue ni API.
+ */
+
+import type { CarrierContactFormDraft } from '~/modules/transport/types/carrierForm'
+
+/** Vrai si une chaîne porte au moins un caractère non-espace. */
+function isFilled(value: string | null | undefined): boolean {
+ return value !== null && value !== undefined && value.trim() !== ''
+}
+
+/**
+ * Un bloc Contact est VIDE tant qu'aucun champ comptant pour la validité n'est
+ * rempli — prénom / nom / fonction / téléphone principal / email. `phoneSecondary`
+ * est EXCLU (aligné M1/M2/M3 : un bloc ne portant qu'un 2e numéro reste vide). Sert
+ * le filtrage des amorces vides à la soumission.
+ */
+export function isCarrierContactBlank(contact: CarrierContactFormDraft): boolean {
+ return ![
+ contact.firstName,
+ contact.lastName,
+ contact.jobTitle,
+ contact.phonePrimary,
+ contact.email,
+ ].some(isFilled)
+}
+
+/**
+ * Un contact est « nommé » (valide) dès qu'il porte un prénom OU un nom — aligné
+ * sur M1/M2/M3. Pilote le gating « + Nouveau contact » : la fonction / le téléphone
+ * / l'email seuls ne suffisent pas pour ajouter un nouveau bloc.
+ */
+export function isCarrierContactNamed(contact: CarrierContactFormDraft): boolean {
+ return isFilled(contact.firstName) || isFilled(contact.lastName)
+}
+
+/**
+ * Payload de la sous-ressource contacts (groupe `carrier:write:contacts`). Les
+ * chaînes vides partent à null (le serveur normalise/trim). Les téléphones sont
+ * regroupés dans le tableau `phones` (numéros non vides, max 2 — RG-4.08) ; le 2e
+ * numéro n'est inclus que s'il a été révélé (`hasSecondaryPhone`).
+ */
+export function buildCarrierContactPayload(contact: CarrierContactFormDraft): Record {
+ const phones = [
+ contact.phonePrimary,
+ contact.hasSecondaryPhone ? contact.phoneSecondary : null,
+ ].filter((phone): phone is string => isFilled(phone))
+
+ return {
+ firstName: contact.firstName || null,
+ lastName: contact.lastName || null,
+ jobTitle: contact.jobTitle || null,
+ email: contact.email || null,
+ phones,
+ }
+}
diff --git a/migrations/Version20260617120000.php b/migrations/Version20260617120000.php
new file mode 100644
index 0000000..edf6d5b
--- /dev/null
+++ b/migrations/Version20260617120000.php
@@ -0,0 +1,49 @@
+addSql('ALTER TABLE carrier_contact DROP CONSTRAINT chk_carrier_contact_filled');
+ $this->addSql('ALTER TABLE carrier_contact ADD CONSTRAINT chk_carrier_contact_name CHECK (first_name IS NOT NULL OR last_name IS NOT NULL)');
+
+ $this->addSql('COMMENT ON TABLE carrier_contact IS $_$Contacts d un transporteur (1:n) — onglet Contact (M4). Au moins le prenom OU le nom rempli (RG-4.08, chk_carrier_contact_name), max 2 telephones.$_$');
+ $this->addSql('COMMENT ON COLUMN carrier_contact.first_name IS $_$Prenom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-4.08, chk_carrier_contact_name).$_$');
+ $this->addSql('COMMENT ON COLUMN carrier_contact.last_name IS $_$Nom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-4.08, chk_carrier_contact_name).$_$');
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('ALTER TABLE carrier_contact DROP CONSTRAINT chk_carrier_contact_name');
+ $this->addSql('ALTER TABLE carrier_contact ADD CONSTRAINT chk_carrier_contact_filled CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR job_title IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL)');
+
+ $this->addSql('COMMENT ON TABLE carrier_contact IS $_$Contacts d un transporteur (1:n) — onglet Contact (M4). Au moins un champ rempli (RG-4.08, chk_carrier_contact_filled), max 2 telephones.$_$');
+ $this->addSql('COMMENT ON COLUMN carrier_contact.first_name IS $_$Prenom du contact (capitalise serveur). Au moins un champ du contact est requis (RG-4.08).$_$');
+ $this->addSql('COMMENT ON COLUMN carrier_contact.last_name IS $_$Nom du contact (capitalise serveur). Au moins un champ du contact est requis (RG-4.08).$_$');
+ }
+}
diff --git a/src/Module/Transport/Domain/Entity/CarrierContact.php b/src/Module/Transport/Domain/Entity/CarrierContact.php
index 93a507a..ddb7924 100644
--- a/src/Module/Transport/Domain/Entity/CarrierContact.php
+++ b/src/Module/Transport/Domain/Entity/CarrierContact.php
@@ -21,8 +21,8 @@ use Symfony\Component\Validator\Constraints as Assert;
/**
* Contact d'un transporteur (1:n) — onglet Contact (M4). Jumeau de
- * SupplierContact (M2) : au moins un champ rempli (RG-4.08, garanti par le
- * CHECK chk_carrier_contact_filled + le Processor), max 2 telephones.
+ * SupplierContact (M2) : au moins le prenom OU le nom (RG-4.08, garanti par le
+ * CHECK chk_carrier_contact_name + le Processor), max 2 telephones.
*
* Lecture : proprietes en `carrier:item:read` (embarquees au detail du
* transporteur). Ecriture : groupe `carrier:write:contacts`.
diff --git a/src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierContactProcessor.php b/src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierContactProcessor.php
index 6291feb..1ca6c7b 100644
--- a/src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierContactProcessor.php
+++ b/src/Module/Transport/Infrastructure/ApiPlatform/State/Processor/CarrierContactProcessor.php
@@ -23,21 +23,21 @@ use function is_string;
/**
* Processor d'ecriture de la sous-ressource Contact d'un transporteur (M4,
* spec-back § 4.5). Jumeau du SupplierContactProcessor (M2), recentre sur le
- * perimetre ERP-160, AVEC deux specificites M4 : RG-4.08 (≥ 1 champ rempli, max
- * 2 telephones) portee a la fois par le CHECK BDD chk_carrier_contact_filled et
- * par ce Processor.
+ * perimetre ERP-160. RG-4.08 (correctif, alignement M1/M2/M3) : un contact exige
+ * au moins le PRENOM OU le NOM (la fonction / le telephone / l'email seuls ne
+ * suffisent pas), porte a la fois par le CHECK BDD chk_carrier_contact_name et par
+ * ce Processor ; le « max 2 telephones » reste une specificite M4.
*
* Sequence :
* - POST / PATCH : rattachement au transporteur parent (linkParent),
* normalisation serveur RG-4.13 (prenom/nom Title Case, email lowercase),
* mapping du tableau d'ecriture `phones` -> phonePrimary/phoneSecondary
- * (max 2, chiffres uniquement), puis garde RG-4.08 (≥ 1 champ) avant
- * persistance.
+ * (max 2, chiffres uniquement), puis garde « prenom OU nom » avant persistance.
* - DELETE : aucune regle metier specifique (suppression physique directe).
*
- * RG-4.08 vit ICI (double du CHECK BDD) pour transformer une violation SQL (500
- * generique) en 422 propre rattachee au champ `firstName` (mapping inline
- * ERP-101). Le « max 2 telephones » est rattache au champ `phones` : seul
+ * La garde « prenom OU nom » vit ICI (double du CHECK BDD) pour transformer une
+ * violation SQL (500 generique) en 422 propre rattachee au champ `firstName`
+ * (mapping inline ERP-101). Le « max 2 telephones » est rattache au champ `phones` : seul
* point de saisie des numeros (les colonnes phonePrimary/phoneSecondary sont en
* lecture seule).
*
@@ -77,7 +77,7 @@ final class CarrierContactProcessor implements ProcessorInterface
$this->linkParent($data, $uriVariables);
$this->normalize($data);
$this->applyPhones($data);
- $this->validateAtLeastOneField($data);
+ $this->validateName($data);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
@@ -187,25 +187,18 @@ final class CarrierContactProcessor implements ProcessorInterface
}
/**
- * RG-4.08 : un bloc Contact est valide des qu'au moins 1 champ est rempli
- * (firstName, lastName, jobTitle, phonePrimary ou email — meme perimetre que
- * le CHECK BDD chk_carrier_contact_filled, qui exclut phoneSecondary). Double
- * garde : leve une 422 propre rattachee a `firstName` plutot qu'une 500 SQL.
- * Joue apres normalisation + mapping telephones, donc les chaines vides sont
- * deja ramenees a null.
+ * RG-4.08 (alignement M1/M2/M3) : un bloc Contact exige au moins le PRENOM OU le
+ * NOM — un contact se materialise par son nom ; fonction / telephone / email
+ * seuls ne suffisent pas. Double garde avec le CHECK BDD chk_carrier_contact_name
+ * — leve une 422 propre rattachee a `firstName` plutot qu'une 500 SQL. Joue apres
+ * normalisation + mapping telephones, donc les chaines vides sont deja null.
*/
- private function validateAtLeastOneField(CarrierContact $contact): void
+ private function validateName(CarrierContact $contact): void
{
- if (
- null === $contact->getFirstName()
- && null === $contact->getLastName()
- && null === $contact->getJobTitle()
- && null === $contact->getPhonePrimary()
- && null === $contact->getEmail()
- ) {
+ if (null === $contact->getFirstName() && null === $contact->getLastName()) {
$violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation(
- 'Renseignez au moins un champ pour le contact.',
+ 'Le prénom ou le nom du contact est obligatoire.',
null,
[],
$contact,
@@ -219,8 +212,8 @@ final class CarrierContactProcessor implements ProcessorInterface
/**
* Trim + chaine vide -> null (la fonction n'est pas normalisee en casse,
- * contrairement aux noms de personne). Garantit que RG-4.08 detecte un champ
- * « non rempli » meme si le client envoie une chaine vide.
+ * contrairement aux noms de personne). Evite de persister une chaine vide
+ * (« » devient null) cote fonction du contact.
*/
private function blankToNull(?string $value): ?string
{
diff --git a/src/Module/Transport/Infrastructure/DataFixtures/CarrierFixtures.php b/src/Module/Transport/Infrastructure/DataFixtures/CarrierFixtures.php
index 0cce512..1cd7aeb 100644
--- a/src/Module/Transport/Infrastructure/DataFixtures/CarrierFixtures.php
+++ b/src/Module/Transport/Infrastructure/DataFixtures/CarrierFixtures.php
@@ -194,7 +194,7 @@ class CarrierFixtures extends Fixture implements DependentFixtureInterface
/**
* Ajoute un contact normalise au transporteur (cascade persist via
- * Carrier.contacts). Au moins un champ est toujours fourni (RG-4.08).
+ * Carrier.contacts). Prenom OU nom toujours fourni (RG-4.08, chk_carrier_contact_name).
*/
private function addContact(
Carrier $carrier,
diff --git a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php
index 6cf7e2d..0c476d3 100644
--- a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php
+++ b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php
@@ -509,11 +509,11 @@ final class ColumnCommentsCatalog
] + self::timestampableBlamableComments(),
'carrier_contact' => [
- '_table' => 'Contacts d un transporteur (1:n) — onglet Contact (M4). Au moins un champ rempli (RG-4.08, chk_carrier_contact_filled), max 2 telephones.',
+ '_table' => 'Contacts d un transporteur (1:n) — onglet Contact (M4). Au moins le prenom OU le nom rempli (RG-4.08, chk_carrier_contact_name), max 2 telephones.',
'id' => 'Identifiant interne auto-incremente.',
'carrier_id' => 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire du contact.',
- 'first_name' => 'Prenom du contact (capitalise serveur). Au moins un champ du contact est requis (RG-4.08).',
- 'last_name' => 'Nom du contact (capitalise serveur). Au moins un champ du contact est requis (RG-4.08).',
+ 'first_name' => 'Prenom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-4.08, chk_carrier_contact_name).',
+ 'last_name' => 'Nom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-4.08, chk_carrier_contact_name).',
'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).',
'phone_primary' => 'Telephone principal — chiffres uniquement (normalisation serveur).',
'phone_secondary' => 'Telephone secondaire — chiffres uniquement (max 2 telephones, RG-4.08).',
diff --git a/tests/Module/Transport/Api/CarrierContactApiTest.php b/tests/Module/Transport/Api/CarrierContactApiTest.php
index 68f1189..aebbcfd 100644
--- a/tests/Module/Transport/Api/CarrierContactApiTest.php
+++ b/tests/Module/Transport/Api/CarrierContactApiTest.php
@@ -15,8 +15,8 @@ use Symfony\Component\Console\Output\NullOutput;
* POST /api/carriers/{id}/contacts, PATCH/DELETE /api/carrier_contacts/{id}.
*
* Contrat verifie :
- * - RG-4.08 : contact totalement vide -> 422 (au moins 1 champ requis) ;
- * - RG-4.08 : 1 seul champ rempli -> 201 ;
+ * - RG-4.08 : contact sans prenom ni nom -> 422 (alignement M1/M2/M3) ;
+ * - RG-4.08 : un nom (ou prenom) suffit -> 201 ;
* - RG-4.08 : 3 telephones (tableau `phones`) -> 422 (max 2) ;
* - mapping `phones[]` -> phonePrimary / phoneSecondary + normalisation (RG-4.13) ;
* - PATCH / DELETE OK avec transport.carriers.manage, 403 sans (view seul).
@@ -51,7 +51,8 @@ final class CarrierContactApiTest extends AbstractCarrierApiTestCase
public function testEmptyContactReturns422(): void
{
- // RG-4.08 : aucun champ rempli -> 422 (garde Processor, double du CHECK BDD).
+ // RG-4.08 (alignement M1/M2/M3) : sans prenom ni nom -> 422 (garde Processor,
+ // double du CHECK BDD chk_carrier_contact_name).
$carrier = $this->seedCarrier('Contact Vide');
$client = $this->createAdminClient();
@@ -60,13 +61,13 @@ final class CarrierContactApiTest extends AbstractCarrierApiTestCase
'json' => [],
]);
self::assertResponseStatusCodeSame(422);
- // RG-4.08 : la violation est rattachee a `firstName` (mapping inline ERP-101).
+ // La violation est rattachee a `firstName` (mapping inline ERP-101).
self::assertViolationOnPath($response, 'firstName');
}
public function testSingleFieldContactIsCreated(): void
{
- // RG-4.08 : un seul champ suffit a valider le bloc.
+ // RG-4.08 : un nom (ou prenom) suffit a valider le bloc.
$carrier = $this->seedCarrier('Contact Mono');
$client = $this->createAdminClient();