fix(transport) : onglet Contact transporteur non obligatoire + navigation onglets (ERP-193)
- retrait de la regle « prenom OU nom » sur le bloc Contact : garde CarrierContactProcessor::validateName supprimee, CHECK chk_carrier_contact_name droppe (migration Version20260619120000), commentaires SQL/catalogue alignes - front : gating « + Nouveau contact » sur bloc non vide (au lieu de « nomme »), onglet Contact vide finalisable sans creer de contact - Prix accessible des la validation des Adresses (Contacts optionnel ne bloque plus) - consultation <-> edition : on retombe sur le meme onglet via ?tab=
This commit is contained in:
@@ -37,7 +37,7 @@ vi.stubGlobal('useToast', () => ({
|
||||
}))
|
||||
|
||||
const { useCarrierForm, CARRIER_TAB_KEYS } = await import('../useCarrierForm')
|
||||
const { buildCarrierContactPayload, isCarrierContactBlank, isCarrierContactNamed } = await import('../../utils/forms/carrierContact')
|
||||
const { buildCarrierContactPayload, isCarrierContactBlank } = await import('../../utils/forms/carrierContact')
|
||||
const { buildCarrierPricePayload, isCarrierPriceValid } = await import('../../utils/forms/carrierPrice')
|
||||
const { emptyCarrierContact, emptyCarrierPrice } = await import('../../types/carrierForm')
|
||||
|
||||
@@ -545,6 +545,9 @@ describe('useCarrierForm — onglet Adresse (ERP-167 / ERP-172 : adresse unique)
|
||||
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
|
||||
expect(form.address.value.id).toBe(88)
|
||||
expect(form.isValidated('addresses')).toBe(true)
|
||||
// ERP-193 : Contact optionnel → valider Adresses déverrouille jusqu'à Prix
|
||||
// (dernier onglet), sans étape bloquante par Contacts.
|
||||
expect(form.unlockedIndex.value).toBe(CARRIER_TAB_KEYS.length - 1)
|
||||
})
|
||||
|
||||
it('submitAddress : PATCH de l\'adresse existante sur /carrier_addresses/{id}', async () => {
|
||||
@@ -577,7 +580,7 @@ describe('useCarrierForm — onglet Adresse (ERP-167 / ERP-172 : adresse unique)
|
||||
})
|
||||
})
|
||||
|
||||
describe('carrierContact (util) — validité alignée M1/M2/M3 + max 2 téléphones', () => {
|
||||
describe('carrierContact (util) — bloc optionnel (ERP-193) + max 2 téléphones', () => {
|
||||
it('isCarrierContactBlank : vrai si vide, faux dès un champ comptant rempli (phoneSecondary exclu)', () => {
|
||||
expect(isCarrierContactBlank(emptyCarrierContact())).toBe(true)
|
||||
expect(isCarrierContactBlank({ ...emptyCarrierContact(), jobTitle: 'Acheteur' })).toBe(false)
|
||||
@@ -586,15 +589,6 @@ describe('carrierContact (util) — validité alignée M1/M2/M3 + max 2 téléph
|
||||
expect(isCarrierContactBlank({ ...emptyCarrierContact(), phoneSecondary: '0605040302', hasSecondaryPhone: true })).toBe(true)
|
||||
})
|
||||
|
||||
it('isCarrierContactNamed : nommé seulement avec un prénom OU un nom', () => {
|
||||
expect(isCarrierContactNamed(emptyCarrierContact())).toBe(false)
|
||||
expect(isCarrierContactNamed({ ...emptyCarrierContact(), firstName: 'Jean' })).toBe(true)
|
||||
expect(isCarrierContactNamed({ ...emptyCarrierContact(), lastName: 'Doe' })).toBe(true)
|
||||
// Fonction / téléphone / email seuls ne « nomment » pas (≠ RG-4.08 large).
|
||||
expect(isCarrierContactNamed({ ...emptyCarrierContact(), jobTitle: 'Acheteur' })).toBe(false)
|
||||
expect(isCarrierContactNamed({ ...emptyCarrierContact(), email: 'a@b.fr' })).toBe(false)
|
||||
})
|
||||
|
||||
it('buildCarrierContactPayload : phones = 1 numéro sans secondaire', () => {
|
||||
const body = buildCarrierContactPayload({ ...emptyCarrierContact(), phonePrimary: '0102030405' })
|
||||
expect(body.phones).toEqual(['0102030405'])
|
||||
@@ -635,23 +629,18 @@ describe('useCarrierForm — onglet Contacts (ERP-168)', () => {
|
||||
return form
|
||||
}
|
||||
|
||||
it('« + Nouveau contact » désactivé tant que le bloc n\'est pas nommé (prénom OU nom, aligné M1/M2/M3)', () => {
|
||||
it('ERP-193 : « + Nouveau contact » désactivé tant que le bloc est VIDE (plus de règle prénom/nom)', () => {
|
||||
const form = createdForm()
|
||||
expect(form.canAddContact.value).toBe(false)
|
||||
|
||||
// addContact est un no-op tant que le bloc n'est pas nommé.
|
||||
// addContact est un no-op tant que le bloc est totalement vide.
|
||||
form.addContact()
|
||||
expect(form.contacts.value).toHaveLength(1)
|
||||
|
||||
// Fonction seule ne suffit PAS à ajouter un nouveau bloc (≠ RG-4.08 large).
|
||||
// ERP-193 : un seul champ rempli (ici la fonction, sans prénom ni nom) suffit
|
||||
// désormais à débloquer l'ajout — la règle « prénom OU nom » est retirée.
|
||||
const first = form.contacts.value[0]
|
||||
if (first) first.jobTitle = 'Acheteur'
|
||||
expect(form.canAddContact.value).toBe(false)
|
||||
form.addContact()
|
||||
expect(form.contacts.value).toHaveLength(1)
|
||||
|
||||
// Un nom (ou prénom) débloque l'ajout.
|
||||
if (first) first.lastName = 'Doe'
|
||||
expect(form.canAddContact.value).toBe(true)
|
||||
form.addContact()
|
||||
expect(form.contacts.value).toHaveLength(2)
|
||||
@@ -686,21 +675,15 @@ describe('useCarrierForm — onglet Contacts (ERP-168)', () => {
|
||||
expect(mockPatch).toHaveBeenCalledWith('/carrier_contacts/55', expect.objectContaining({ lastName: 'Doe' }), { toast: false })
|
||||
})
|
||||
|
||||
it('RG-4.08 : onglet vide → soumet l\'amorce pour déclencher la 422 inline', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: { violations: [{ propertyPath: 'firstName', message: 'Au moins un champ du contact est obligatoire.' }] },
|
||||
},
|
||||
})
|
||||
it('ERP-193 : onglet Contact vide → aucun POST, onglet finalisé (bloc optionnel)', async () => {
|
||||
const form = createdForm()
|
||||
|
||||
// Bloc vide → rien n'est soumis, l'onglet se finalise et déverrouille Prix.
|
||||
const ok = await form.submitContacts(vi.fn())
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(mockPost).toHaveBeenCalledTimes(1)
|
||||
expect(form.contactErrors.value[0]?.firstName).toBe('Au moins un champ du contact est obligatoire.')
|
||||
expect(form.isValidated('contacts')).toBe(false)
|
||||
expect(ok).toBe(true)
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(form.isValidated('contacts')).toBe(true)
|
||||
})
|
||||
|
||||
it('removeContact : DELETE /carrier_contacts/{id} puis retrait du bloc', async () => {
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type CarrierPriceFormDraft,
|
||||
} from '~/modules/transport/types/carrierForm'
|
||||
import { buildCarrierAddressPayload } from '~/modules/transport/utils/forms/carrierAddress'
|
||||
import { buildCarrierContactPayload, isCarrierContactBlank, isCarrierContactNamed } from '~/modules/transport/utils/forms/carrierContact'
|
||||
import { buildCarrierContactPayload, isCarrierContactBlank } from '~/modules/transport/utils/forms/carrierContact'
|
||||
import { buildCarrierPricePayload, isCarrierPriceValid } from '~/modules/transport/utils/forms/carrierPrice'
|
||||
import {
|
||||
mapAddressToDraft,
|
||||
@@ -488,6 +488,10 @@ export function useCarrierForm() {
|
||||
await api.patch(`/carrier_addresses/${address.value.id}`, body, { toast: false })
|
||||
}
|
||||
completeTab('addresses')
|
||||
// ERP-193 : l'onglet Contact est OPTIONNEL — il ne doit pas verrouiller
|
||||
// l'accès à Prix. Dès les Adresses validées, on déverrouille jusqu'à Prix
|
||||
// (Contacts reste accessible mais n'est plus une étape bloquante).
|
||||
unlockedIndex.value = tabKeys.value.length - 1
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
@@ -511,12 +515,13 @@ export function useCarrierForm() {
|
||||
// Erreurs 422 par ligne (alignées sur l'index du v-for), peuplées par submitRows.
|
||||
const contactErrors = ref<Record<string, string>[]>([])
|
||||
|
||||
// « + Nouveau contact » désactivé tant que le DERNIER bloc n'est pas « nommé »
|
||||
// (prénom OU nom) — aligné sur M1/M2/M3 (fonction / téléphone / email seuls ne
|
||||
// suffisent pas à ajouter un nouveau bloc).
|
||||
// « + Nouveau contact » désactivé tant que le DERNIER bloc est VIDE. ERP-193 :
|
||||
// l'onglet Contact n'est plus obligatoire — on ne réclame plus prénom OU nom,
|
||||
// un seul champ rempli (fonction / téléphone / email) suffit pour empiler un
|
||||
// bloc suivant (et évite d'accumuler des blocs totalement vides).
|
||||
const canAddContact = computed(() => {
|
||||
const last = contacts.value[contacts.value.length - 1]
|
||||
return last !== undefined && isCarrierContactNamed(last)
|
||||
return last !== undefined && !isCarrierContactBlank(last)
|
||||
})
|
||||
|
||||
function addContact(): void {
|
||||
@@ -541,10 +546,11 @@ export function useCarrierForm() {
|
||||
/**
|
||||
* Valide l'onglet Contacts : POST des nouveaux contacts sur
|
||||
* /carriers/{id}/contacts, PATCH des existants sur /carrier_contacts/{id}
|
||||
* (groupe carrier:write:contacts). RG-4.08 (≥ 1 champ rempli, max 2 téléphones)
|
||||
* re-validée back → 422 par ligne. Si l'onglet ne contient QUE des amorces
|
||||
* vides, on soumet la 1re pour déclencher la 422 RG-4.08 inline plutôt que de
|
||||
* finaliser un onglet vide. Retourne true si l'onglet a été validé.
|
||||
* (groupe carrier:write:contacts). Max 2 téléphones re-validé back → 422 par
|
||||
* ligne. ERP-193 : l'onglet Contact est OPTIONNEL — les amorces vides neuves
|
||||
* sont systématiquement ignorées (pas de contact vide créé) et un onglet sans
|
||||
* aucun bloc rempli est simplement finalisé, déverrouillant l'onglet Prix.
|
||||
* Retourne true si l'onglet a été validé.
|
||||
*/
|
||||
async function submitContacts(onError: (error: unknown) => void): Promise<boolean> {
|
||||
if (carrierId.value === null || tabSubmitting.value) {
|
||||
@@ -552,7 +558,6 @@ export function useCarrierForm() {
|
||||
}
|
||||
tabSubmitting.value = true
|
||||
try {
|
||||
const hasSubmittable = contacts.value.some(c => c.id !== null || !isCarrierContactBlank(c))
|
||||
const hasError = await submitRows(
|
||||
contacts.value,
|
||||
contactErrors,
|
||||
@@ -571,9 +576,9 @@ export function useCarrierForm() {
|
||||
}
|
||||
},
|
||||
onError,
|
||||
// Amorce vide neuve ignorée s'il reste un autre bloc soumettable ;
|
||||
// sinon on la soumet pour déclencher la 422 RG-4.08 (sur firstName).
|
||||
contact => hasSubmittable && contact.id === null && isCarrierContactBlank(contact),
|
||||
// Amorce vide neuve toujours ignorée (bloc Contact optionnel, ERP-193) :
|
||||
// un onglet sans aucun bloc rempli se finalise sans rien créer.
|
||||
contact => contact.id === null && isCarrierContactBlank(contact),
|
||||
)
|
||||
if (hasError) {
|
||||
return false
|
||||
|
||||
@@ -291,9 +291,13 @@ const TAB_ICONS: Record<string, string> = {
|
||||
contacts: 'mdi:account-box-plus-outline',
|
||||
prices: 'mdi:payment',
|
||||
}
|
||||
const activeTab = ref('addresses')
|
||||
// Onglet Qualimat disponible en modif (ERP-172) : « actualiser » nom + certification.
|
||||
const tabs = computed(() => ['qualimat', 'addresses', 'contacts', 'prices'].map(key => ({
|
||||
const TAB_KEYS = ['qualimat', 'addresses', 'contacts', 'prices']
|
||||
// ERP-193 : on honore l'onglet demande via `?tab=` (navigation depuis la
|
||||
// consultation) pour retomber sur le meme onglet ; defaut « addresses ».
|
||||
const requestedTab = typeof route.query.tab === 'string' ? route.query.tab : ''
|
||||
const activeTab = ref(TAB_KEYS.includes(requestedTab) ? requestedTab : 'addresses')
|
||||
const tabs = computed(() => TAB_KEYS.map(key => ({
|
||||
key,
|
||||
label: t(`transport.carriers.tab.${key}`),
|
||||
icon: TAB_ICONS[key],
|
||||
@@ -371,7 +375,8 @@ function onIndexationInput(value: string): void {
|
||||
}
|
||||
|
||||
function goBack(): void {
|
||||
router.push(`/carriers/${carrierId}`)
|
||||
// ERP-193 : on transmet l'onglet courant pour retomber dessus en consultation.
|
||||
router.push({ path: `/carriers/${carrierId}`, query: { tab: activeTab.value } })
|
||||
}
|
||||
|
||||
/** PATCH du formulaire principal (pas de re-POST). */
|
||||
|
||||
@@ -312,15 +312,19 @@ const tabs = computed(() => visibleTabKeys.value.map(key => ({
|
||||
})))
|
||||
|
||||
// Onglet initial : vide tant que le transporteur n'est pas charge, puis premier
|
||||
// onglet visible. Un watcher recale si l'onglet courant disparait.
|
||||
// onglet visible. Un watcher recale si l'onglet courant disparait. ERP-193 : on
|
||||
// honore l'onglet demande via `?tab=` (navigation depuis l'edition) s'il est
|
||||
// visible, pour retomber sur le meme onglet en passant edition <-> consultation.
|
||||
const activeTab = ref('')
|
||||
let requestedTab = typeof route.query.tab === 'string' ? route.query.tab : ''
|
||||
watch(visibleTabKeys, (keys) => {
|
||||
if (keys.length === 0) {
|
||||
activeTab.value = ''
|
||||
return
|
||||
}
|
||||
if (!keys.includes(activeTab.value)) {
|
||||
activeTab.value = keys[0]
|
||||
activeTab.value = requestedTab && keys.includes(requestedTab) ? requestedTab : keys[0]
|
||||
requestedTab = ''
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
@@ -498,7 +502,8 @@ function goBack(): void {
|
||||
}
|
||||
|
||||
function goEdit(): void {
|
||||
router.push(`/carriers/${carrierId}/edit`)
|
||||
// ERP-193 : on transmet l'onglet courant pour retomber dessus en edition.
|
||||
router.push({ path: `/carriers/${carrierId}/edit`, query: activeTab.value ? { tab: activeTab.value } : {} })
|
||||
}
|
||||
|
||||
const confirmArchive = reactive({ open: false, title: '', message: '', confirmLabel: '' })
|
||||
|
||||
@@ -198,8 +198,8 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Onglet Contacts (ERP-168) : un bloc par contact (RG-4.08 ≥ 1 champ,
|
||||
max 2 téléphones). Erreurs 422 par ligne. -->
|
||||
<!-- Onglet Contacts (ERP-168) : un bloc par contact (bloc optionnel,
|
||||
ERP-193 ; max 2 téléphones). Erreurs 422 par ligne. -->
|
||||
<template #contacts>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<CarrierContactBlock
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* 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.
|
||||
* Helpers purs de l'onglet Contact transporteur (M4 Transport, ERP-168). ERP-193
|
||||
* (retour métier) : l'onglet Contact n'est plus obligatoire — la règle « prénom OU
|
||||
* nom » est retirée. Le gating « + Nouveau contact » repose désormais sur « le
|
||||
* dernier bloc n'est pas vide » (et non plus « nommé »). 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'
|
||||
@@ -30,15 +30,6 @@ export function isCarrierContactBlank(contact: CarrierContactFormDraft): boolean
|
||||
].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
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* ERP-193 (retour metier) — l'onglet Contact d'un transporteur n'est plus
|
||||
* obligatoire : suppression de la garde « prenom OU nom » (ex RG-4.08). Drop du
|
||||
* CHECK chk_carrier_contact_name et mise a jour des commentaires de colonnes. La
|
||||
* garde applicative (CarrierContactProcessor::validateName) est retiree dans le
|
||||
* meme commit ; le catalogue ColumnCommentsCatalog aussi.
|
||||
*
|
||||
* Placee au namespace racine DoctrineMigrations (et non en modulaire Transport) :
|
||||
* elle ALTERE une table creee par une migration racine (Version20260615150000),
|
||||
* comme la migration qui avait introduit le CHECK (Version20260617120000) ; le tri
|
||||
* par version au sein du meme namespace garantit qu'elle joue APRES (cf. CLAUDE.md
|
||||
* regle 11 — le tri cross-namespace casserait l'ordre sur base vide).
|
||||
*/
|
||||
final class Version20260619120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'ERP-193 : onglet Contact transporteur non obligatoire — drop du CHECK chk_carrier_contact_name.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE carrier_contact DROP CONSTRAINT chk_carrier_contact_name');
|
||||
|
||||
$this->addSql('COMMENT ON TABLE carrier_contact IS $_$Contacts d un transporteur (1:n) — onglet Contact (M4). Bloc optionnel (ERP-193) ; max 2 telephones.$_$');
|
||||
$this->addSql('COMMENT ON COLUMN carrier_contact.first_name IS $_$Prenom du contact (capitalise serveur). Optionnel (ERP-193).$_$');
|
||||
$this->addSql('COMMENT ON COLUMN carrier_contact.last_name IS $_$Nom du contact (capitalise serveur). Optionnel (ERP-193).$_$');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$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).$_$');
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,9 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Contact d'un transporteur (1:n) — onglet Contact (M4). Jumeau de
|
||||
* 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.
|
||||
* Contact d'un transporteur (1:n) — onglet Contact (M4). ERP-193 (retour metier) :
|
||||
* le bloc Contact est OPTIONNEL — la garde « prenom OU nom » (ex RG-4.08) est
|
||||
* retiree. Reste applicable : max 2 telephones.
|
||||
*
|
||||
* Lecture : proprietes en `carrier:item:read` (embarquees au detail du
|
||||
* transporteur). Ecriture : groupe `carrier:write:contacts`.
|
||||
|
||||
+8
-38
@@ -23,23 +23,19 @@ 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. 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.
|
||||
* perimetre ERP-160. ERP-193 (retour metier) : l'onglet Contact n'est plus
|
||||
* obligatoire — la garde « prenom OU nom » (ex RG-4.08) est retiree, un contact
|
||||
* peut donc etre cree sans nom. 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 « prenom OU nom » avant persistance.
|
||||
* (max 2, chiffres uniquement) avant persistance.
|
||||
* - DELETE : aucune regle metier specifique (suppression physique directe).
|
||||
*
|
||||
* 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).
|
||||
* Le « max 2 telephones » est rattache au champ `phones` : seul point de saisie
|
||||
* des numeros (les colonnes phonePrimary/phoneSecondary sont en lecture seule).
|
||||
*
|
||||
* La security d'operation (transport.carriers.manage) est appliquee par API
|
||||
* Platform en amont, de meme que la validation Symfony des contraintes d'attribut
|
||||
@@ -77,7 +73,6 @@ final class CarrierContactProcessor implements ProcessorInterface
|
||||
$this->linkParent($data, $uriVariables);
|
||||
$this->normalize($data);
|
||||
$this->applyPhones($data);
|
||||
$this->validateName($data);
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
@@ -115,9 +110,8 @@ final class CarrierContactProcessor implements ProcessorInterface
|
||||
|
||||
/**
|
||||
* Normalisation serveur RG-4.13 des champs texte. Toutes les methodes du
|
||||
* normalizer sont null-safe : une chaine vide apres trim devient null (donc la
|
||||
* garde RG-4.08 detecte bien « champ non rempli »). Les telephones sont
|
||||
* traites a part (applyPhones).
|
||||
* normalizer sont null-safe : une chaine vide apres trim devient null. Les
|
||||
* telephones sont traites a part (applyPhones).
|
||||
*/
|
||||
private function normalize(CarrierContact $contact): void
|
||||
{
|
||||
@@ -186,30 +180,6 @@ final class CarrierContactProcessor implements ProcessorInterface
|
||||
$contact->setPhones(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 validateName(CarrierContact $contact): void
|
||||
{
|
||||
if (null === $contact->getFirstName() && null === $contact->getLastName()) {
|
||||
$violations = new ConstraintViolationList();
|
||||
$violations->add(new ConstraintViolation(
|
||||
'Le prénom ou le nom du contact est obligatoire.',
|
||||
null,
|
||||
[],
|
||||
$contact,
|
||||
'firstName',
|
||||
null,
|
||||
));
|
||||
|
||||
throw new ValidationException($violations);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim + chaine vide -> null (la fonction n'est pas normalisee en casse,
|
||||
* contrairement aux noms de personne). Evite de persister une chaine vide
|
||||
|
||||
@@ -195,7 +195,8 @@ class CarrierFixtures extends Fixture implements DependentFixtureInterface
|
||||
|
||||
/**
|
||||
* Ajoute un contact normalise au transporteur (cascade persist via
|
||||
* Carrier.contacts). Prenom OU nom toujours fourni (RG-4.08, chk_carrier_contact_name).
|
||||
* Carrier.contacts). Le bloc Contact est optionnel (ERP-193) ; les fixtures
|
||||
* fournissent neanmoins un nom pour des donnees de demonstration realistes.
|
||||
*/
|
||||
private function addContact(
|
||||
Carrier $carrier,
|
||||
|
||||
@@ -508,11 +508,11 @@ final class ColumnCommentsCatalog
|
||||
] + self::timestampableBlamableComments(),
|
||||
|
||||
'carrier_contact' => [
|
||||
'_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.',
|
||||
'_table' => 'Contacts d un transporteur (1:n) — onglet Contact (M4). Bloc optionnel (ERP-193) ; 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). 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).',
|
||||
'first_name' => 'Prenom du contact (capitalise serveur). Optionnel (ERP-193).',
|
||||
'last_name' => 'Nom du contact (capitalise serveur). Optionnel (ERP-193).',
|
||||
'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).',
|
||||
|
||||
@@ -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 sans prenom ni nom -> 422 (alignement M1/M2/M3) ;
|
||||
* - RG-4.08 : un nom (ou prenom) suffit -> 201 ;
|
||||
* - ERP-193 : contact sans prenom ni nom -> 201 (bloc Contact optionnel) ;
|
||||
* - un nom (ou prenom) seul 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).
|
||||
@@ -49,25 +49,25 @@ final class CarrierContactApiTest extends AbstractCarrierApiTestCase
|
||||
self::ensureKernelShutdown();
|
||||
}
|
||||
|
||||
public function testEmptyContactReturns422(): void
|
||||
public function testContactWithoutNameIsCreated(): void
|
||||
{
|
||||
// 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');
|
||||
// ERP-193 (retour metier) : l'onglet Contact n'est plus obligatoire et la
|
||||
// garde « prenom OU nom » (ex RG-4.08) est retiree -> un contact sans nom
|
||||
// (ici un simple telephone) est desormais accepte (201).
|
||||
$carrier = $this->seedCarrier('Contact Sans Nom');
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [
|
||||
$client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [],
|
||||
'json' => ['phones' => ['0611111111']],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
// La violation est rattachee a `firstName` (mapping inline ERP-101).
|
||||
self::assertViolationOnPath($response, 'firstName');
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
self::assertJsonContains(['phonePrimary' => '0611111111']);
|
||||
}
|
||||
|
||||
public function testSingleFieldContactIsCreated(): void
|
||||
{
|
||||
// RG-4.08 : un nom (ou prenom) suffit a valider le bloc.
|
||||
// Un nom (ou prenom) seul suffit a creer un contact.
|
||||
$carrier = $this->seedCarrier('Contact Mono');
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user