From c76c447aa2184a51269b4c868764f6b4cf573ac2 Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 15 Jun 2026 09:29:44 +0000 Subject: [PATCH] feat(front) : consultation + modification prestataire (ERP-145) (#107) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empilée sur ERP-144 (#106). ## Périmètre ERP-145 Écrans **Consultation** (lecture seule) et **Modification** (édition par onglet), peuplés depuis la **seule** réponse `GET /api/providers/{id}` (embed contacts/adresses/ribs + refs comptables — pas de N+1). ### Consultation — `pages/providers/[id]/index.vue` (`/providers/{id}`) - Ouverture par défaut sur **Contacts** ; tous champs readonly ; onglets **Contacts · Adresse · Rapports · Échanges · Comptabilité** (navigation libre). Rapports/Échanges = placeholders « À venir ». - Flèche retour → répertoire. Bouton **Modifier** (si `manage` OU `accounting.manage`). Bouton **Archiver** (Admin seul, `archive`) → modal → PATCH `{isArchived:true}` ; **Restaurer** si archivé. - Comptabilité visible seulement si `accounting.view` ; banque/RIB affichés selon le type de règlement (VIREMENT/LCR). ### Modification — `pages/providers/[id]/edit.vue` (`/providers/{id}/edit`) - Pré-rempli ; **bloc principal éditable** (Nom/Catégories/Sites, PATCH `provider:write:main` via `updateMain`) ; onglets Contact/Adresse/Comptabilité en **navigation libre**, PATCH partiel par onglet (réutilise `useProviderForm` en `editMode`). - Onglets sans permission `manage` / `accounting.manage` restent **readonly** (pas de bouton Valider / suppression). Accès réservé à `manage` OU `accounting.manage`. ### Composables / helpers - **`useProvider(id)`** : charge le détail (ld+json) + archive/restore (PATCH isArchived seul, puis rechargement). - **`useProviderForm`** étendu : `updateMain()` (PATCH principal en édition) + `editMode` (completeTab ne verrouille/avance plus). - **`providerDetail.ts`** : mapping embed → brouillons + options role-indépendantes (libellés depuis l'embed) + règles d'actions (Modifier/Archiver/Restaurer). ## Conformité - `useApi()` only ; `Malio*` only ; `usePermissions()` pour boutons/onglets ; aucun texte FR en dur ; pas d'import inter-module (règle ABSOLUE n°1). ## Vérifications - Vitest : 470/470 (16 nouveaux : mapping détail, actions par permission, updateMain + editMode). - ESLint : OK · `nuxi typecheck` : 0 erreur sur les fichiers source du ticket. - Golden path navigateur : **Consultation** (ACME) — bloc principal readonly + libellés catégories/sites résolus depuis l'embed, 5 onglets, Modifier+Archiver visibles (admin), Comptabilité readonly. **Modification** — bloc principal éditable pré-rempli (Site « 86 17 »), 3 onglets navigation libre, onglet Contact pré-rempli. Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/107 Co-authored-by: tristan Co-committed-by: tristan --- frontend/i18n/locales/fr.json | 29 +- .../__tests__/useProviderForm.test.ts | 82 ++- .../technique/composables/useProvider.ts | 70 +++ .../technique/composables/useProviderForm.ts | 58 +- .../technique/pages/providers/[id]/edit.vue | 538 ++++++++++++++++++ .../technique/pages/providers/[id]/index.vue | 308 ++++++++++ .../modules/technique/pages/providers/new.vue | 37 +- .../forms/__tests__/providerContact.spec.ts | 24 +- .../forms/__tests__/providerDetail.spec.ts | 167 ++++++ .../technique/utils/forms/providerContact.ts | 13 +- .../technique/utils/forms/providerDetail.ts | 245 ++++++++ migrations/Version20260615120000.php | 50 ++ ...roviderAccountingCompletenessValidator.php | 79 +++ .../Processor/ProviderContactProcessor.php | 19 +- .../State/Processor/ProviderProcessor.php | 30 + .../Database/ColumnCommentsCatalog.php | 8 +- .../Api/ProviderAccountingValidationTest.php | 59 +- .../Api/ProviderSubResourceApiTest.php | 67 ++- 18 files changed, 1812 insertions(+), 71 deletions(-) create mode 100644 frontend/modules/technique/composables/useProvider.ts create mode 100644 frontend/modules/technique/pages/providers/[id]/edit.vue create mode 100644 frontend/modules/technique/pages/providers/[id]/index.vue create mode 100644 frontend/modules/technique/utils/forms/__tests__/providerDetail.spec.ts create mode 100644 frontend/modules/technique/utils/forms/providerDetail.ts create mode 100644 migrations/Version20260615120000.php create mode 100644 src/Module/Technique/Application/Validator/ProviderAccountingCompletenessValidator.php diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 85d206e..7e1bb0f 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -390,9 +390,32 @@ }, "tab": { "contact": "Contact", + "contacts": "Contacts", "address": "Adresse", + "reports": "Rapports", + "exchanges": "Échanges", "accounting": "Comptabilité" }, + "action": { + "edit": "Modifier", + "archive": "Archiver", + "restore": "Restaurer" + }, + "consultation": { + "title": "Fiche prestataire", + "back": "Retour au répertoire", + "loading": "Chargement…", + "notFound": "Prestataire introuvable.", + "confirmArchive": "Archiver ce prestataire ? Il n'apparaîtra plus dans le répertoire actif.", + "confirmRestore": "Restaurer ce prestataire ? Il réapparaîtra dans le répertoire actif." + }, + "edit": { + "title": "Modifier le prestataire", + "back": "Retour à la fiche", + "loading": "Chargement…", + "notFound": "Prestataire introuvable.", + "save": "Enregistrer" + }, "form": { "title": "Ajouter un prestataire", "back": "Précédent", @@ -404,6 +427,7 @@ "sites": "Site" }, "errors": { + "nameRequired": "Le nom du prestataire est obligatoire.", "siteRequired": "Sélectionnez au moins un site.", "categoryRequired": "Sélectionnez au moins une catégorie." }, @@ -459,7 +483,10 @@ "error": "Une erreur est survenue. Réessayez.", "exportError": "L'export du répertoire prestataires a échoué. Réessayez.", "createSuccess": "Prestataire créé avec succès", - "updateSuccess": "Prestataire mis à jour avec succès" + "updateSuccess": "Prestataire mis à jour avec succès", + "addComplete": "Prestataire ajouté", + "archiveSuccess": "Prestataire archivé avec succès", + "restoreSuccess": "Prestataire restauré avec succès" } } }, diff --git a/frontend/modules/technique/composables/__tests__/useProviderForm.test.ts b/frontend/modules/technique/composables/__tests__/useProviderForm.test.ts index a4a7c4f..a0ab65b 100644 --- a/frontend/modules/technique/composables/__tests__/useProviderForm.test.ts +++ b/frontend/modules/technique/composables/__tests__/useProviderForm.test.ts @@ -69,14 +69,14 @@ describe('useProviderForm', () => { permState.accountingManage = false }) - it('RG-3.03/RG-3.09 (front) : bloque le POST si aucun site / aucune categorie', async () => { + it('front : formulaire principal vide -> erreurs sur nom + site + categorie, pas de POST', async () => { const form = useProviderForm() - form.main.companyName = 'Maintenance Pro' const created = await form.submitMain() expect(created).toBe(false) expect(mockPost).not.toHaveBeenCalled() + expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.errors.nameRequired') expect(form.mainErrors.errors.sites).toBe('technique.providers.form.errors.siteRequired') expect(form.mainErrors.errors.categories).toBe('technique.providers.form.errors.categoryRequired') expect(form.mainLocked.value).toBe(false) @@ -122,18 +122,17 @@ describe('useProviderForm', () => { expect(form.unlockedIndex.value).toBe(0) }) - it('omet companyName vide du payload (laisse la 422 NotBlank back mordre)', async () => { - mockPost.mockResolvedValueOnce({ id: 1, companyName: null }) + it('front : nom vide/espaces -> erreur inline sur companyName, pas de POST', async () => { const form = useProviderForm() form.main.companyName = ' ' form.main.categoryIris = [CAT_MAINT] form.main.siteIris = [SITE_86] - await form.submitMain() + const created = await form.submitMain() - const body = (mockPost.mock.calls[0] ?? [])[1] as Record - expect(body).not.toHaveProperty('companyName') - expect(body).toEqual({ categories: [CAT_MAINT], sites: [SITE_86] }) + expect(created).toBe(false) + expect(mockPost).not.toHaveBeenCalled() + expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.errors.nameRequired') }) it('409 doublon (RG-3.10) : erreur inline dediee sur companyName, pas de verrouillage', async () => { @@ -585,3 +584,70 @@ describe('useProviderForm — onglet Comptabilite (ERP-144)', () => { expect(mockPatch).not.toHaveBeenCalled() }) }) + +describe('useProviderForm — modification (ERP-145)', () => { + beforeEach(() => { + mockPost.mockReset() + mockPatch.mockReset() + permState.accountingView = false + permState.accountingManage = false + }) + + it('editMode : completeTab ne verrouille pas et ne bascule pas d\'onglet', () => { + const form = useProviderForm() + form.editMode.value = true + form.activeTab.value = 'contact' + + expect(form.completeTab('contact')).toBe(false) + expect(form.isValidated('contact')).toBe(false) + expect(form.activeTab.value).toBe('contact') + }) + + it('updateMain : PATCH /providers/{id} sur le groupe principal (pas de POST)', async () => { + mockPatch.mockResolvedValueOnce({ id: 7, companyName: 'MAINTENANCE PRO' }) + const form = useProviderForm() + form.providerId.value = 7 + form.main.companyName = 'Maintenance Pro' + form.main.categoryIris = [CAT_MAINT] + form.main.siteIris = [SITE_86] + + const ok = await form.updateMain() + + expect(ok).toBe(true) + expect(mockPost).not.toHaveBeenCalled() + expect(mockPatch).toHaveBeenCalledWith( + '/providers/7', + { companyName: 'Maintenance Pro', categories: [CAT_MAINT], sites: [SITE_86] }, + { toast: false }, + ) + // Reaffiche le nom normalise renvoye par le serveur. + expect(form.main.companyName).toBe('MAINTENANCE PRO') + }) + + it('updateMain : RG-3.03 front -> bloque le PATCH sans site', async () => { + const form = useProviderForm() + form.providerId.value = 7 + form.main.companyName = 'X' + form.main.categoryIris = [CAT_MAINT] + + const ok = await form.updateMain() + + expect(ok).toBe(false) + expect(mockPatch).not.toHaveBeenCalled() + expect(form.mainErrors.errors.sites).toBe('technique.providers.form.errors.siteRequired') + }) + + it('updateMain : 409 doublon -> erreur inline sur companyName', async () => { + mockPatch.mockRejectedValueOnce({ response: { status: 409 } }) + const form = useProviderForm() + form.providerId.value = 7 + form.main.companyName = 'Doublon' + form.main.categoryIris = [CAT_MAINT] + form.main.siteIris = [SITE_86] + + const ok = await form.updateMain() + + expect(ok).toBe(false) + expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.duplicateCompany') + }) +}) diff --git a/frontend/modules/technique/composables/useProvider.ts b/frontend/modules/technique/composables/useProvider.ts new file mode 100644 index 0000000..db795e2 --- /dev/null +++ b/frontend/modules/technique/composables/useProvider.ts @@ -0,0 +1,70 @@ +import { ref } from 'vue' +import type { ProviderDetail } from '~/modules/technique/utils/forms/providerDetail' + +/** + * Chargement et actions d'archivage d'un prestataire unique (ecrans Consultation / + * Modification, ERP-145). Miroir de `useSupplier` (M2). Lit le detail embarque via + * `GET /api/providers/{id}` (contacts / adresses + leurs sous-collections / ribs + * sous `provider:item:read` / `provider:read:accounting`) — une SEULE requete + * peuple les deux ecrans (embed borne, pas de N+1). + * + * L'en-tete `Accept: application/ld+json` est impose pour obtenir le payload Hydra + * complet (avec les `@id` des relations embarquees, indispensables au pre-remplissage). + * + * Etat 100 % local a l'instance (refs). Les erreurs d'archivage / restauration + * (notamment le 409 d'homonyme actif a la restauration) sont PROPAGEES a l'appelant, + * qui decide du toast a afficher. + */ +export function useProvider(id: number | string) { + const api = useApi() + + const provider = ref(null) + const loading = ref(false) + const error = ref(false) + + /** Recupere le detail complet (embed contacts/adresses/ribs + comptabilite). */ + function fetchDetail(): Promise { + return api.get( + `/providers/${id}`, + {}, + { headers: { Accept: 'application/ld+json' }, toast: false }, + ) + } + + /** Charge le detail du prestataire. En cas d'echec : `error = true`, `provider = null`. */ + async function load(): Promise { + loading.value = true + error.value = false + try { + provider.value = await fetchDetail() + } + catch { + error.value = true + provider.value = null + } + finally { + loading.value = false + } + } + + /** + * Bascule l'archivage (PATCH `isArchived` SEUL — groupe provider:write:archive ; + * tout autre champ => 422), puis RECHARGE le detail complet : la reponse du PATCH + * ne porte que `provider:read` (ni l'embed des sous-collections ni les libelles + * comptables), un simple merge laisserait l'affichage incoherent. Toute erreur est + * propagee a l'appelant AVANT le rechargement. + */ + async function setArchived(isArchived: boolean): Promise { + await api.patch(`/providers/${id}`, { isArchived }, { toast: false }) + provider.value = await fetchDetail() + } + + return { + provider, + loading, + error, + load, + archive: () => setArchived(true), + restore: () => setArchived(false), + } +} diff --git a/frontend/modules/technique/composables/useProviderForm.ts b/frontend/modules/technique/composables/useProviderForm.ts index af1e090..34e7e91 100644 --- a/frontend/modules/technique/composables/useProviderForm.ts +++ b/frontend/modules/technique/composables/useProviderForm.ts @@ -20,6 +20,7 @@ import { import { buildProviderContactPayload, isProviderContactBlank, + isProviderContactNamed, } from '~/modules/technique/utils/forms/providerContact' import { buildProviderAddressPayload, @@ -91,6 +92,9 @@ export function useProviderForm() { const activeTab = ref('contact') // Onglets valides (passent en lecture seule). const validated = reactive>({}) + // Mode MODIFICATION (ERP-145) : navigation libre, pas de verrouillage ni de + // bascule automatique d'onglet a la validation (cf. completeTab). + const editMode = ref(false) function isValidated(key: string): boolean { return validated[key] === true @@ -108,6 +112,10 @@ export function useProviderForm() { */ function validateMainFront(): boolean { let valid = true + if (!main.companyName?.trim()) { + mainErrors.setError('companyName', t('technique.providers.form.errors.nameRequired')) + valid = false + } if (main.siteIris.length === 0) { mainErrors.setError('sites', t('technique.providers.form.errors.siteRequired')) valid = false @@ -192,12 +200,55 @@ export function useProviderForm() { await api.patch(`/providers/${providerId.value}`, payload, { toast: false }) } + /** + * MODIFICATION du bloc principal (ERP-145) : PATCH /providers/{id} sur le groupe + * provider:write:main (nom + categories + sites). Pre-check front RG-3.03/3.09, + * 409 doublon de nom (RG-3.10) et 422 mappes inline comme a la creation. A la + * difference de `submitMain`, ne verrouille rien et ne bascule pas d'onglet (la + * navigation est libre en modification). Retourne true si le PATCH a reussi. + */ + async function updateMain(): Promise { + if (providerId.value === null || mainSubmitting.value) return false + mainErrors.clearErrors() + if (!validateMainFront()) return false + + mainSubmitting.value = true + try { + const updated = await api.patch( + `/providers/${providerId.value}`, + buildMainPayload(), + { toast: false }, + ) + main.companyName = updated.companyName ?? main.companyName + return true + } + catch (error) { + const status = (error as { response?: { status?: number } })?.response?.status + if (status === 409) { + const message = t('technique.providers.form.duplicateCompany') + mainErrors.setError('companyName', message) + toast.error({ title: t('technique.providers.toast.error'), message }) + } + else { + mainErrors.handleApiError(error, { fallbackMessage: t('technique.providers.toast.error') }) + } + return false + } + finally { + mainSubmitting.value = false + } + } + /** * Marque un onglet valide (passe en lecture seule), deverrouille et avance a * l'onglet suivant. Retourne true si c'etait le dernier onglet du flux * (creation terminee), false sinon. */ function completeTab(key: string): boolean { + // En modification : navigation libre, l'onglet reste editable apres validation. + if (editMode.value) { + return false + } validated[key] = true const index = tabIndex(key) const next = tabKeys.value[index + 1] @@ -253,10 +304,11 @@ export function useProviderForm() { // Erreurs 422 par ligne (alignees sur l'index du v-for), peuplees par submitRows. const contactErrors = ref[]>([]) - // « + Nouveau contact » desactive tant que le dernier bloc est vide (RG-3.04). + // « + Nouveau contact » desactive tant que le dernier bloc n'a pas de nom OU + // prenom (RG-3.04, aligne M1/M2 — fonction/tel/email seuls ne suffisent pas). const canAddContact = computed(() => { const last = contacts.value[contacts.value.length - 1] - return last !== undefined && !isProviderContactBlank(last) + return last !== undefined && isProviderContactNamed(last) }) function addContact(): void { @@ -521,6 +573,7 @@ export function useProviderForm() { activeTab, unlockedIndex, validated, + editMode, isValidated, // contacts contacts, @@ -551,6 +604,7 @@ export function useProviderForm() { validateMainFront, buildMainPayload, submitMain, + updateMain, patchProvider, completeTab, submitRows, diff --git a/frontend/modules/technique/pages/providers/[id]/edit.vue b/frontend/modules/technique/pages/providers/[id]/edit.vue new file mode 100644 index 0000000..1340e21 --- /dev/null +++ b/frontend/modules/technique/pages/providers/[id]/edit.vue @@ -0,0 +1,538 @@ + + + diff --git a/frontend/modules/technique/pages/providers/[id]/index.vue b/frontend/modules/technique/pages/providers/[id]/index.vue new file mode 100644 index 0000000..fb6bfb4 --- /dev/null +++ b/frontend/modules/technique/pages/providers/[id]/index.vue @@ -0,0 +1,308 @@ + + + diff --git a/frontend/modules/technique/pages/providers/new.vue b/frontend/modules/technique/pages/providers/new.vue index 7054103..5b9862d 100644 --- a/frontend/modules/technique/pages/providers/new.vue +++ b/frontend/modules/technique/pages/providers/new.vue @@ -85,7 +85,7 @@ @@ -121,7 +121,7 @@ @@ -251,7 +251,7 @@ @@ -314,6 +314,7 @@ const referentials = useProviderReferentials() const { main, + providerId, mainLocked, mainSubmitting, mainErrors, @@ -362,15 +363,33 @@ function apiErrorMessage(error: unknown): string { return extractApiErrorMessage(data) || t('technique.providers.toast.error') } +// Dernier onglet REMPLISSABLE par le role : tabKeys exclut deja la Comptabilite +// si l'user n'a pas accounting.view. Sa validation cloture l'ajout (redirection). +const lastFillableTab = computed(() => tabKeys.value[tabKeys.value.length - 1]) + +/** + * Apres validation d'un onglet (creation) : si c'est le dernier onglet du role, + * l'ajout est termine -> toast final + retour au repertoire (miroir M1/M2) ; sinon + * toast de mise a jour (l'onglet suivant a deja ete deverrouille par completeTab). + */ +function onTabSaved(key: string): void { + if (key === lastFillableTab.value) { + toast.success({ title: t('technique.providers.toast.addComplete') }) + router.push('/providers') + return + } + toast.success({ title: t('technique.providers.toast.updateSuccess') }) +} + // ── Onglet Contact ────────────────────────────────────────────────────────── -/** Valide l'onglet Contact ; toast de succes si l'onglet a ete finalise. */ +/** Valide l'onglet Contact ; redirige si c'est le dernier onglet du role. */ async function onSubmitContacts(): Promise { const ok = await submitContacts(error => toast.error({ title: t('technique.providers.toast.error'), message: apiErrorMessage(error), })) if (ok) { - toast.success({ title: t('technique.providers.toast.updateSuccess') }) + onTabSaved('contact') } } @@ -413,14 +432,14 @@ function onAddressDegraded(): void { }) } -/** Valide l'onglet Adresse ; toast de succes si l'onglet a ete finalise. */ +/** Valide l'onglet Adresse ; redirige si c'est le dernier onglet du role. */ async function onSubmitAddresses(): Promise { const ok = await submitAddresses(error => toast.error({ title: t('technique.providers.toast.error'), message: apiErrorMessage(error), })) if (ok) { - toast.success({ title: t('technique.providers.toast.updateSuccess') }) + onTabSaved('address') } } @@ -450,7 +469,7 @@ function askRemoveRib(index: number): void { askConfirm(t('technique.providers.form.confirmDelete.rib'), () => removeRib(index)) } -/** Valide l'onglet Comptabilite ; toast de succes si l'onglet a ete finalise. */ +/** Valide l'onglet Comptabilite ; redirige si c'est le dernier onglet du role. */ async function onSubmitAccounting(): Promise { const ok = await submitAccounting( isBankRequired.value, @@ -461,7 +480,7 @@ async function onSubmitAccounting(): Promise { }), ) if (ok) { - toast.success({ title: t('technique.providers.toast.updateSuccess') }) + onTabSaved('accounting') } } diff --git a/frontend/modules/technique/utils/forms/__tests__/providerContact.spec.ts b/frontend/modules/technique/utils/forms/__tests__/providerContact.spec.ts index 721f391..0b8f8a4 100644 --- a/frontend/modules/technique/utils/forms/__tests__/providerContact.spec.ts +++ b/frontend/modules/technique/utils/forms/__tests__/providerContact.spec.ts @@ -3,6 +3,7 @@ import { buildProviderContactPayload, hasAtLeastOneFilledContact, isProviderContactBlank, + isProviderContactNamed, } from '../providerContact' import { emptyProviderContact } from '~/modules/technique/types/providerForm' @@ -34,15 +35,28 @@ describe('providerContact helpers', () => { }) }) - describe('hasAtLeastOneFilledContact (RG-3.12)', () => { - it('false si tous les blocs sont vides', () => { - expect(hasAtLeastOneFilledContact([emptyProviderContact(), emptyProviderContact()])).toBe(false) + describe('isProviderContactNamed (RG-3.04 — prenom OU nom)', () => { + it('vrai avec un prenom seul ou un nom seul', () => { + expect(isProviderContactNamed({ ...emptyProviderContact(), firstName: 'Jean' })).toBe(true) + expect(isProviderContactNamed({ ...emptyProviderContact(), lastName: 'Dupont' })).toBe(true) }) - it('true des qu\'un bloc porte une donnee', () => { + it('faux si seuls fonction / telephone / email sont remplis (ne suffit pas)', () => { + expect(isProviderContactNamed({ ...emptyProviderContact(), jobTitle: 'Directeur' })).toBe(false) + expect(isProviderContactNamed({ ...emptyProviderContact(), email: 'a@b.fr' })).toBe(false) + expect(isProviderContactNamed({ ...emptyProviderContact(), phonePrimary: '0102030405' })).toBe(false) + }) + }) + + describe('hasAtLeastOneFilledContact (RG-3.12 — au moins un contact nomme)', () => { + it('false si aucun bloc n\'est nomme', () => { + expect(hasAtLeastOneFilledContact([emptyProviderContact(), { ...emptyProviderContact(), email: 'a@b.fr' }])).toBe(false) + }) + + it('true des qu\'un bloc porte un nom ou prenom', () => { expect(hasAtLeastOneFilledContact([ emptyProviderContact(), - { ...emptyProviderContact(), email: 'a@b.fr' }, + { ...emptyProviderContact(), lastName: 'Dupont' }, ])).toBe(true) }) }) diff --git a/frontend/modules/technique/utils/forms/__tests__/providerDetail.spec.ts b/frontend/modules/technique/utils/forms/__tests__/providerDetail.spec.ts new file mode 100644 index 0000000..e939f96 --- /dev/null +++ b/frontend/modules/technique/utils/forms/__tests__/providerDetail.spec.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, vi } from 'vitest' + +// formatPhoneFR est auto-importe dans le helper via le chemin partage ; on le mocke +// pour un rendu deterministe (la mise en forme exacte est testee ailleurs). +vi.mock('~/shared/utils/phone', () => ({ + formatPhoneFR: (v: string) => `fmt(${v})`, +})) + +const { + canEditProvider, + categoryOptionsOf, + contactOptionsOf, + iriOf, + irisOf, + mapAccountingDraft, + mapAddressToDraft, + mapContactToDraft, + mapRibToDraft, + paymentTypeCodeOf, + referentialOptionOf, + showArchiveAction, + showRestoreAction, + siteOptionsOf, +} = await import('../providerDetail') + +/** + * Helpers purs des ecrans Consultation / Modification (ERP-145) : mapping du + * detail embarque vers les brouillons + regles d'affichage des actions (Modifier / + * Archiver / Restaurer). + */ +describe('providerDetail helpers', () => { + describe('iriOf / irisOf', () => { + it('extrait l\'IRI d\'un objet embarque, d\'un IRI nu, ou null', () => { + expect(iriOf({ '@id': '/api/banks/2' })).toBe('/api/banks/2') + expect(iriOf('/api/banks/2')).toBe('/api/banks/2') + expect(iriOf(null)).toBeNull() + expect(iriOf(undefined)).toBeNull() + }) + + it('extrait les IRI d\'une collection embarquee', () => { + expect(irisOf([{ '@id': '/api/sites/1' }, { '@id': '/api/sites/2' }])).toEqual(['/api/sites/1', '/api/sites/2']) + expect(irisOf(undefined)).toEqual([]) + }) + }) + + describe('mapContactToDraft', () => { + it('mappe les champs, formate les telephones et derive hasSecondaryPhone', () => { + const draft = mapContactToDraft({ + '@id': '/api/provider_contacts/5', + id: 5, + firstName: 'Jean', + lastName: 'Dupont', + phonePrimary: '0102030405', + phoneSecondary: '0607080910', + email: 'jean@x.fr', + }) + expect(draft).toMatchObject({ + id: 5, + iri: '/api/provider_contacts/5', + firstName: 'Jean', + lastName: 'Dupont', + phonePrimary: 'fmt(0102030405)', + phoneSecondary: 'fmt(0607080910)', + email: 'jean@x.fr', + hasSecondaryPhone: true, + }) + }) + + it('hasSecondaryPhone faux sans 2e numero', () => { + const draft = mapContactToDraft({ '@id': '/api/provider_contacts/6', id: 6, lastName: 'Doe' }) + expect(draft.hasSecondaryPhone).toBe(false) + expect(draft.phoneSecondary).toBeNull() + }) + }) + + describe('mapAddressToDraft', () => { + it('extrait les IRI des sites / categories / contacts embarques', () => { + const draft = mapAddressToDraft({ + '@id': '/api/provider_addresses/3', + id: 3, + country: 'France', + postalCode: '86100', + city: 'Châtellerault', + street: '1 rue du Test', + sites: [{ '@id': '/api/sites/1' }], + categories: [{ '@id': '/api/categories/7' }], + contacts: [{ '@id': '/api/provider_contacts/5' }, '/api/provider_contacts/6'], + }) + expect(draft.siteIris).toEqual(['/api/sites/1']) + expect(draft.categoryIris).toEqual(['/api/categories/7']) + expect(draft.contactIris).toEqual(['/api/provider_contacts/5', '/api/provider_contacts/6']) + expect(draft.id).toBe(3) + }) + }) + + describe('mapAccountingDraft / mapRibToDraft', () => { + it('mappe les scalaires et les IRI des referentiels embarques', () => { + const draft = mapAccountingDraft({ + '@id': '/api/providers/9', + id: 9, + siren: '123456789', + accountNumber: '4010', + nTva: 'FR123', + tvaMode: { '@id': '/api/tva_modes/1', label: 'TVA' }, + paymentType: { '@id': '/api/payment_types/3', code: 'VIREMENT' }, + bank: { '@id': '/api/banks/2' }, + }) + expect(draft.tvaModeIri).toBe('/api/tva_modes/1') + expect(draft.paymentTypeIri).toBe('/api/payment_types/3') + expect(draft.bankIri).toBe('/api/banks/2') + expect(draft.paymentDelayIri).toBeNull() + expect(draft.siren).toBe('123456789') + }) + + it('mappe un RIB embarque', () => { + expect(mapRibToDraft({ '@id': '/api/provider_ribs/1', id: 1, label: 'Compte', bic: 'BIC', iban: 'IBAN' })) + .toEqual({ id: 1, label: 'Compte', bic: 'BIC', iban: 'IBAN' }) + }) + }) + + describe('options builders (libelles role-independants depuis l\'embed)', () => { + it('categoryOptionsOf / siteOptionsOf / contactOptionsOf', () => { + expect(categoryOptionsOf([{ '@id': '/api/categories/7', name: 'Maintenance', code: 'MAINT' }])) + .toEqual([{ value: '/api/categories/7', label: 'Maintenance' }]) + expect(siteOptionsOf([{ '@id': '/api/sites/1', name: 'Châtellerault' }])) + .toEqual([{ value: '/api/sites/1', label: 'Châtellerault' }]) + expect(contactOptionsOf([{ '@id': '/api/provider_contacts/5', id: 5, firstName: 'Jean', lastName: 'Dupont' }])) + .toEqual([{ value: '/api/provider_contacts/5', label: 'Jean Dupont' }]) + }) + + it('referentialOptionOf / paymentTypeCodeOf', () => { + expect(referentialOptionOf({ '@id': '/api/banks/2', label: 'SG' })) + .toEqual([{ value: '/api/banks/2', label: 'SG' }]) + expect(referentialOptionOf(null)).toEqual([]) + expect(referentialOptionOf('/api/banks/2')).toEqual([]) + expect(paymentTypeCodeOf({ '@id': '/api/payment_types/3', code: 'LCR' })).toBe('LCR') + expect(paymentTypeCodeOf(null)).toBeNull() + }) + }) + + describe('actions selon permissions', () => { + /** Fabrique un `can` qui n'autorise que les codes fournis. */ + const canFor = (granted: string[]) => (code: string) => granted.includes(code) + const canAnyFor = (granted: string[]) => (codes: string[]) => codes.some(c => granted.includes(c)) + + it('« Modifier » visible avec manage OU accounting.manage (Compta inclus)', () => { + expect(canEditProvider(canAnyFor(['technique.providers.manage']))).toBe(true) + expect(canEditProvider(canAnyFor(['technique.providers.accounting.manage']))).toBe(true) + expect(canEditProvider(canAnyFor(['technique.providers.view']))).toBe(false) + }) + + it('« Archiver » visible seulement avec archive ET prestataire actif (Admin seul)', () => { + const admin = canFor(['technique.providers.archive']) + const bureau = canFor(['technique.providers.manage']) + expect(showArchiveAction(admin, false)).toBe(true) + expect(showArchiveAction(admin, true)).toBe(false) // deja archive -> Restaurer + expect(showArchiveAction(bureau, false)).toBe(false) // pas la permission archive + }) + + it('« Restaurer » visible seulement avec archive ET prestataire archive', () => { + const admin = canFor(['technique.providers.archive']) + expect(showRestoreAction(admin, true)).toBe(true) + expect(showRestoreAction(admin, false)).toBe(false) + expect(showRestoreAction(canFor([]), true)).toBe(false) + }) + }) +}) diff --git a/frontend/modules/technique/utils/forms/providerContact.ts b/frontend/modules/technique/utils/forms/providerContact.ts index d8121b9..41f0074 100644 --- a/frontend/modules/technique/utils/forms/providerContact.ts +++ b/frontend/modules/technique/utils/forms/providerContact.ts @@ -32,12 +32,21 @@ export function isProviderContactBlank(contact: ProviderContactFormDraft): boole ].some(isFilled) } +/** + * RG-3.04 : un contact est « nomme » (valide) des qu'il porte un prenom OU un nom + * — aligne sur le M1/M2. Sert le gating « + Nouveau contact » et la notion de + * contact valide (la fonction / le telephone / l'email seuls ne suffisent pas). + */ +export function isProviderContactNamed(contact: ProviderContactFormDraft): boolean { + return isFilled(contact.firstName) || isFilled(contact.lastName) +} + /** * RG-3.12 : l'onglet Contact ne peut etre finalise que s'il reste au moins un - * bloc non vide (au moins un contact valide). + * contact nomme (prenom ou nom). */ export function hasAtLeastOneFilledContact(contacts: ProviderContactFormDraft[]): boolean { - return contacts.some(contact => !isProviderContactBlank(contact)) + return contacts.some(isProviderContactNamed) } /** diff --git a/frontend/modules/technique/utils/forms/providerDetail.ts b/frontend/modules/technique/utils/forms/providerDetail.ts new file mode 100644 index 0000000..36bbd04 --- /dev/null +++ b/frontend/modules/technique/utils/forms/providerDetail.ts @@ -0,0 +1,245 @@ +/** + * Helpers purs des ecrans Consultation / Modification prestataire (M3 Technique, + * ERP-145) — miroir SIMPLIFIE de `supplierConsultation.ts` (M2). Mappent le payload + * `GET /api/providers/{id}` (relations embarquees, cf. groupes `provider:item:read` + * + `provider:read:accounting`) vers les brouillons « plats » partages avec + * `ProviderContactBlock` / `ProviderAddressBlock` et l'onglet Comptabilite. + * + * Ne touchent ni a l'API ni a l'etat reactif (testables unitairement). + * + * Rappels de contrat back (JSON reel fige — ERP-139, spec-back § 4.0.bis) : + * - categories / sites du prestataire et des adresses : OBJETS embarques (avec @id) ; + * - refs comptables (tvaMode/paymentDelay/paymentType/bank) : OBJETS embarques + * `{@id, id, label, (code pour paymentType)}` ; + * - champs nuls OMIS (skip_null_values) → toujours lire avec `?? null` ; + * - champs comptables + `ribs` TOTALEMENT ABSENTS sans permission accounting.view. + * + * Differences M2 : pas de type d'adresse / bennes / triage, pas d'onglet Information. + */ + +import { formatPhoneFR } from '~/shared/utils/phone' +import type { + ProviderAccountingDraft, + ProviderAddressFormDraft, + ProviderContactFormDraft, + ProviderRibFormDraft, +} from '~/modules/technique/types/providerForm' +import type { RefOption } from '~/modules/technique/composables/useProviderReferentials' + +/** Reference Hydra embarquee minimale (@id toujours present). */ +export interface HydraRef { + '@id': string + [key: string]: unknown +} + +/** Une relation peut etre embarquee (objet), un IRI nu (chaine) ou absente. */ +export type Relation = HydraRef | string | null | undefined + +/** Site embarque (groupe site:read). */ +export interface SiteRead extends HydraRef { + name?: string + postalCode?: string + color?: string +} + +/** Categorie embarquee (groupe category:read). */ +export interface CategoryRead extends HydraRef { + code?: string + name?: string +} + +/** Contact embarque (groupe provider:item:read). */ +export interface ContactRead extends HydraRef { + id: number + firstName?: string | null + lastName?: string | null + jobTitle?: string | null + phonePrimary?: string | null + phoneSecondary?: string | null + email?: string | null +} + +/** Adresse embarquee (groupe provider:item:read) — version simplifiee M3. */ +export interface AddressRead extends HydraRef { + id: number + country?: string | null + postalCode?: string | null + city?: string | null + street?: string | null + streetComplement?: string | null + sites?: SiteRead[] + categories?: CategoryRead[] + // L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu. + contacts?: Array +} + +/** RIB embarque (groupe provider:read:accounting, present ssi accounting.view). */ +export interface RibRead extends HydraRef { + id: number + label?: string | null + bic?: string | null + iban?: string | null +} + +/** + * Detail d'un prestataire (`GET /api/providers/{id}`). Tous les champs sont + * optionnels : skip_null_values + gating accounting peuvent omettre n'importe + * quelle cle. + */ +export interface ProviderDetail extends HydraRef { + id: number + companyName?: string | null + isArchived?: boolean + categories?: CategoryRead[] + sites?: SiteRead[] + contacts?: ContactRead[] + addresses?: AddressRead[] + ribs?: RibRead[] + // Onglet Comptabilite (present ssi accounting.view) + siren?: string | null + accountNumber?: string | null + nTva?: string | null + tvaMode?: Relation + paymentDelay?: Relation + paymentType?: Relation + bank?: Relation +} + +/** Extrait l'IRI d'une relation (objet embarque, IRI nu, ou null si absente). */ +export function iriOf(relation: Relation): string | null { + if (relation === null || relation === undefined) { + return null + } + if (typeof relation === 'string') { + return relation + } + return relation['@id'] ?? null +} + +/** IRI des elements d'une collection embarquee (categories / sites du prestataire). */ +export function irisOf(items: HydraRef[] | undefined): string[] { + return (items ?? []).map(i => i['@id']) +} + +/** Mappe un contact embarque vers un brouillon (telephones formates XX XX XX XX XX). */ +export function mapContactToDraft(contact: ContactRead): ProviderContactFormDraft { + const phoneSecondary = contact.phoneSecondary ?? null + return { + id: contact.id, + iri: contact['@id'] ?? null, + firstName: contact.firstName ?? null, + lastName: contact.lastName ?? null, + jobTitle: contact.jobTitle ?? null, + phonePrimary: contact.phonePrimary ? formatPhoneFR(contact.phonePrimary) : null, + phoneSecondary: phoneSecondary ? formatPhoneFR(phoneSecondary) : null, + email: contact.email ?? null, + hasSecondaryPhone: phoneSecondary !== null && phoneSecondary !== '', + } +} + +/** Mappe une adresse embarquee vers un brouillon (IRI extraits des sous-collections). */ +export function mapAddressToDraft(address: AddressRead): ProviderAddressFormDraft { + return { + id: address.id, + country: address.country ?? 'France', + postalCode: address.postalCode ?? null, + city: address.city ?? null, + street: address.street ?? null, + streetComplement: address.streetComplement ?? null, + categoryIris: (address.categories ?? []).map(c => c['@id']), + siteIris: (address.sites ?? []).map(s => s['@id']), + contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])), + } +} + +/** Mappe un RIB embarque vers un brouillon. */ +export function mapRibToDraft(rib: RibRead): ProviderRibFormDraft { + return { + id: rib.id, + label: rib.label ?? null, + bic: rib.bic ?? null, + iban: rib.iban ?? null, + } +} + +/** Mappe les champs comptables (scalaires + IRI des referentiels embarques). */ +export function mapAccountingDraft(provider: ProviderDetail): ProviderAccountingDraft { + return { + siren: provider.siren ?? null, + accountNumber: provider.accountNumber ?? null, + nTva: provider.nTva ?? null, + tvaModeIri: iriOf(provider.tvaMode), + paymentDelayIri: iriOf(provider.paymentDelay), + paymentTypeIri: iriOf(provider.paymentType), + bankIri: iriOf(provider.bank), + } +} + +/** + * Options de categories (value=IRI, label=nom) construites depuis l'embed. + * Source role-independante : evite de dependre de `GET /categories` (403 possible + * pour un role metier), qui laisserait les libelles vides en consultation. + */ +export function categoryOptionsOf(categories: CategoryRead[] | undefined): RefOption[] { + return (categories ?? []).map(c => ({ + value: c['@id'], + label: c.name ?? c.code ?? c['@id'], + })) +} + +/** Options de sites (value=IRI, label=nom) construites depuis un embed. */ +export function siteOptionsOf(sites: SiteRead[] | undefined): RefOption[] { + return (sites ?? []).map(s => ({ value: s['@id'], label: s.name ?? s['@id'] })) +} + +/** Options de contacts (value=IRI, label=nom complet ou email) depuis l'embed prestataire. */ +export function contactOptionsOf(contacts: ContactRead[] | undefined): RefOption[] { + return (contacts ?? []).map(c => ({ + value: c['@id'], + label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? c['@id']), + })) +} + +/** + * Liste a une seule option (ou vide) construite depuis un referentiel embarque + * (TvaMode / PaymentDelay / PaymentType / Bank) pour alimenter un MalioSelect en + * lecture seule. Le libelle vient de l'embed, jamais d'un GET de referentiel — + * l'affichage reste correct quel que soit le role. + */ +export function referentialOptionOf(relation: Relation): RefOption[] { + if (!relation || typeof relation === 'string') { + return [] + } + const label = (relation.label as string | undefined) + ?? (relation.name as string | undefined) + ?? relation['@id'] + return [{ value: relation['@id'], label }] +} + +/** Code metier d'un referentiel embarque (PaymentType.code = 'LCR' / 'VIREMENT'), ou null. */ +export function paymentTypeCodeOf(relation: Relation): string | null { + if (!relation || typeof relation === 'string') { + return null + } + return (relation.code as string | undefined) ?? null +} + +/** + * Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet — + * `manage` (onglets metier) OU `accounting.manage` (le role Compta doit pouvoir + * ouvrir l'edition pour son onglet Comptabilite). Le readonly fin par onglet est + * gere sur l'ecran d'edition. + */ +export function canEditProvider(canAny: (codes: string[]) => boolean): boolean { + return canAny(['technique.providers.manage', 'technique.providers.accounting.manage']) +} + +/** Bouton « Archiver » : permission archive ET prestataire encore actif (Admin seul). */ +export function showArchiveAction(can: (code: string) => boolean, isArchived: boolean): boolean { + return can('technique.providers.archive') && !isArchived +} + +/** Bouton « Restaurer » : permission archive ET prestataire deja archive (Admin seul). */ +export function showRestoreAction(can: (code: string) => boolean, isArchived: boolean): boolean { + return can('technique.providers.archive') && isArchived +} diff --git a/migrations/Version20260615120000.php b/migrations/Version20260615120000.php new file mode 100644 index 0000000..381206b --- /dev/null +++ b/migrations/Version20260615120000.php @@ -0,0 +1,50 @@ +addSql('ALTER TABLE provider_contact DROP CONSTRAINT chk_provider_contact_name'); + $this->addSql('ALTER TABLE provider_contact ADD CONSTRAINT chk_provider_contact_name CHECK (first_name IS NOT NULL OR last_name IS NOT NULL)'); + + $this->addSql('COMMENT ON TABLE provider_contact IS $_$Contacts d un prestataire (1:n) — au moins le prenom OU le nom rempli (RG-3.04, chk_provider_contact_name).$_$'); + $this->addSql('COMMENT ON COLUMN provider_contact.first_name IS $_$Prenom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).$_$'); + $this->addSql('COMMENT ON COLUMN provider_contact.last_name IS $_$Nom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).$_$'); + $this->addSql('COMMENT ON COLUMN provider_contact.job_title IS $_$Fonction / intitule de poste du contact (≤ 120 caracteres). Facultatif — ne suffit plus a valider le contact (RG-3.04).$_$'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE provider_contact DROP CONSTRAINT chk_provider_contact_name'); + $this->addSql('ALTER TABLE provider_contact ADD CONSTRAINT chk_provider_contact_name 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 provider_contact IS $_$Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/fonction/telephone/email (RG-3.04, chk_provider_contact_name).$_$'); + $this->addSql('COMMENT ON COLUMN provider_contact.first_name IS $_$Prenom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).$_$'); + $this->addSql('COMMENT ON COLUMN provider_contact.last_name IS $_$Nom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).$_$'); + $this->addSql('COMMENT ON COLUMN provider_contact.job_title IS $_$Fonction / intitule de poste du contact (≤ 120 caracteres). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).$_$'); + } +} diff --git a/src/Module/Technique/Application/Validator/ProviderAccountingCompletenessValidator.php b/src/Module/Technique/Application/Validator/ProviderAccountingCompletenessValidator.php new file mode 100644 index 0000000..11d39da --- /dev/null +++ b/src/Module/Technique/Application/Validator/ProviderAccountingCompletenessValidator.php @@ -0,0 +1,79 @@ + valeur courante des champs obligatoires de l'onglet. + $fields = [ + 'siren' => $provider->getSiren(), + 'accountNumber' => $provider->getAccountNumber(), + 'tvaMode' => $provider->getTvaMode(), + 'nTva' => $provider->getNTva(), + 'paymentDelay' => $provider->getPaymentDelay(), + 'paymentType' => $provider->getPaymentType(), + ]; + + $violations = new ConstraintViolationList(); + + foreach ($fields as $property => $value) { + if ($this->isMissing($value)) { + $violations->add(new ConstraintViolation( + 'Ce champ est obligatoire.', + null, + [], + $provider, + $property, + $value, + )); + } + } + + if (count($violations) > 0) { + throw new ValidationException($violations); + } + } + + /** + * Une valeur est manquante si null ou, pour une chaine, vide apres trim. Les + * references (TvaMode / PaymentDelay / PaymentType) ne sont manquantes que + * lorsqu'elles valent null. + */ + private function isMissing(mixed $value): bool + { + if (null === $value) { + return true; + } + + return is_string($value) && '' === trim($value); + } +} diff --git a/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderContactProcessor.php b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderContactProcessor.php index 21ee45c..10674f6 100644 --- a/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderContactProcessor.php +++ b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderContactProcessor.php @@ -119,23 +119,18 @@ final class ProviderContactProcessor implements ProcessorInterface } /** - * RG-3.04 : un bloc Contact est valide des qu'au moins un champ parmi prenom / - * nom / fonction / telephone principal / email est renseigne (double garde avec - * le CHECK BDD chk_provider_contact_name — leve une 422 propre rattachee au - * champ `firstName` plutot qu'une 500 SQL). Joue apres normalisation, donc les - * chaines vides (y compris une fonction ou un phone_secondary vides) sont deja - * ramenees a null et ne suffisent pas a valider le bloc. + * RG-3.04 : un bloc Contact exige au moins le prenom OU le nom (aligne sur le + * M1/M2 — un contact se materialise par son nom ; fonction / telephone / email + * seuls ne suffisent pas). Double garde avec le CHECK BDD chk_provider_contact_name + * — leve une 422 propre rattachee au champ `firstName` plutot qu'une 500 SQL. + * Joue apres normalisation (les chaines vides sont deja ramenees a null). */ private function validateName(ProviderContact $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( - 'Au moins un champ du contact est obligatoire (nom, prénom, fonction, téléphone ou email).', + 'Le prénom ou le nom du contact est obligatoire.', null, [], $contact, diff --git a/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderProcessor.php b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderProcessor.php index 0552d13..8df9e9b 100644 --- a/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderProcessor.php +++ b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderProcessor.php @@ -9,6 +9,7 @@ use ApiPlatform\State\ProcessorInterface; use ApiPlatform\Validator\Exception\ValidationException; use App\Module\Core\Domain\Entity\User; use App\Module\Technique\Application\Service\ProviderFieldNormalizer; +use App\Module\Technique\Application\Validator\ProviderAccountingCompletenessValidator; use App\Module\Technique\Domain\Entity\Provider; use App\Shared\Domain\Contract\SiteInterface; use DateTimeImmutable; @@ -75,6 +76,15 @@ final class ProviderProcessor implements ProcessorInterface 'paymentType', 'bank', ]; + /** + * Champs comptables obligatoires a la validation complete de l'onglet + * (spec-front M3 § Onglet Comptabilite — miroir M1/M2). bank est exclu : + * conditionnel (RG-3.07). + */ + private const array ACCOUNTING_REQUIRED_FIELDS = [ + 'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay', 'paymentType', + ]; + /** Champ d'archivage (groupe provider:write:archive). */ private const string ARCHIVE_FIELD = 'isArchived'; @@ -102,6 +112,7 @@ final class ProviderProcessor implements ProcessorInterface private readonly Security $security, private readonly RequestStack $requestStack, private readonly EntityManagerInterface $em, + private readonly ProviderAccountingCompletenessValidator $accountingValidator, ) {} public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed @@ -128,6 +139,10 @@ final class ProviderProcessor implements ProcessorInterface // deux cotes (l'etat persiste l'a deja ete). $this->guardManage($data); + // Completude de l'onglet Comptabilite (apres normalize : les chaines vides + // sont deja ramenees a null). Joue uniquement sur une soumission d'onglet. + $this->validateAccountingCompleteness($data); + try { return $this->persistProcessor->process($data, $operation, $uriVariables, $context); } catch (UniqueConstraintViolationException $e) { @@ -496,6 +511,21 @@ final class ProviderProcessor implements ProcessorInterface * * @return list */ + /** + * Completude de l'onglet Comptabilite (miroir SupplierProcessor) : ne se + * declenche que si TOUS les champs requis sont presents dans le payload + * (= soumission d'onglet, pas un PATCH partiel cible). Delegue au validateur + * qui leve une 422 listant chaque champ manquant (mapping inline ERP-101). + */ + private function validateAccountingCompleteness(Provider $data): void + { + if ([] !== array_diff(self::ACCOUNTING_REQUIRED_FIELDS, $this->payloadKeys())) { + return; + } + + $this->accountingValidator->validate($data); + } + private function payloadKeys(): array { $request = $this->requestStack->getCurrentRequest(); diff --git a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php index 13f5770..a6f6dee 100644 --- a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php +++ b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php @@ -395,12 +395,12 @@ final class ColumnCommentsCatalog ], 'provider_contact' => [ - '_table' => 'Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/telephone/email (RG-3.04, chk_provider_contact_name).', + '_table' => 'Contacts d un prestataire (1:n) — au moins le prenom OU le nom rempli (RG-3.04, chk_provider_contact_name).', 'id' => 'Identifiant interne auto-incremente.', 'provider_id' => 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire du contact.', - 'first_name' => 'Prenom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).', - 'last_name' => 'Nom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).', - 'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).', + 'first_name' => 'Prenom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).', + 'last_name' => 'Nom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-3.04, chk_provider_contact_name).', + 'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres). Facultatif — ne suffit plus a valider le contact (RG-3.04).', 'phone_primary' => 'Telephone principal du contact — chiffres uniquement (normalisation serveur).', 'phone_secondary' => 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).', 'email' => 'Email du contact (lowercase serveur).', diff --git a/tests/Module/Technique/Api/ProviderAccountingValidationTest.php b/tests/Module/Technique/Api/ProviderAccountingValidationTest.php index aecbd2f..fde7d3e 100644 --- a/tests/Module/Technique/Api/ProviderAccountingValidationTest.php +++ b/tests/Module/Technique/Api/ProviderAccountingValidationTest.php @@ -9,9 +9,9 @@ namespace App\Tests\Module\Technique\Api; * de l'entite Provider (M3, RG-3.07 / RG-3.08), via le PATCH de l'onglet * Comptabilite (groupe provider:write:accounting). On asserte le code HTTP et le * propertyPath de la violation (consommable par extractApiViolations cote front, - * ERP-101). Jumeau de SupplierAccountingApiTest (M2), sans le bloc « completude de - * l'onglet » : le prestataire est minimal et n'impose pas les six scalaires - * comptables (spec M3 § 3.1). + * ERP-101). Jumeau de SupplierAccountingApiTest (M2), completude de l'onglet + * INCLUSE : a la validation complete de l'onglet, les six scalaires comptables + * sont obligatoires (spec-front M3 § Onglet Comptabilite — aligne M1/M2). * * @internal */ @@ -81,5 +81,58 @@ final class ProviderAccountingValidationTest extends AbstractProviderApiTestCase self::assertResponseStatusCodeSame(200); } + // === Completude de l'onglet Comptabilite (six scalaires obligatoires) === + + /** + * spec-front M3 § Onglet Comptabilite : a la validation COMPLETE de l'onglet + * (les six champs requis presents dans le payload), chacun vide doit renvoyer + * une 422 sur son propre propertyPath (mapping inline front, ERP-101). Miroir + * M1/M2 (ProviderAccountingCompletenessValidator). + */ + public function testIncompleteAccountingTabReturns422OnEachField(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedProvider('Accounting Incomplete'); + + $response = $client->request('PATCH', '/api/providers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE, 'Accept' => self::LD], + 'json' => [ + 'siren' => null, + 'accountNumber' => null, + 'tvaMode' => null, + 'nTva' => null, + 'paymentDelay' => null, + 'paymentType' => null, + ], + ]); + + self::assertResponseStatusCodeSame(422); + $paths = $this->violationsByPath($response->toArray(false)); + self::assertArrayHasKey('siren', $paths); + self::assertArrayHasKey('accountNumber', $paths); + self::assertArrayHasKey('tvaMode', $paths); + self::assertArrayHasKey('nTva', $paths); + self::assertArrayHasKey('paymentDelay', $paths); + self::assertArrayHasKey('paymentType', $paths); + } + + /** + * Un PATCH ciblant un sous-ensemble de champs comptables n'est PAS une + * validation d'onglet : la completude ne se declenche pas (edition ponctuelle + * preservee, cf. validateAccountingCompleteness). + */ + public function testPartialAccountingPatchSkipsCompleteness(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedProvider('Accounting Partial'); + + $client->request('PATCH', '/api/providers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['nTva' => 'FR12345678901'], + ]); + + self::assertResponseStatusCodeSame(200); + } + // violationsByPath() : helper mutualise dans AbstractProviderApiTestCase. } diff --git a/tests/Module/Technique/Api/ProviderSubResourceApiTest.php b/tests/Module/Technique/Api/ProviderSubResourceApiTest.php index fc252bb..1063fa9 100644 --- a/tests/Module/Technique/Api/ProviderSubResourceApiTest.php +++ b/tests/Module/Technique/Api/ProviderSubResourceApiTest.php @@ -9,7 +9,7 @@ use App\Module\Technique\Domain\Entity\Provider; /** * Tests fonctionnels des sous-ressources Contacts / Adresses / RIB du prestataire * (M3, spec § 4.5 — ERP-135). Couvrent : normalisation contact (RG-3.11), RG-3.04 - * (au moins un champ parmi prenom/nom/fonction/telephone/email), RG-3.05 (>= 1 site sur + * (au moins le prenom OU le nom — aligne M1/M2), RG-3.05 (>= 1 site sur * l'adresse), RG-3.06 (code postal), RG-3.09 (categorie PRESTATAIRE sur adresse), * le cloisonnement d'ecriture des sites de l'adresse (§ 2.13 -> 422 sur `sites`), * RG-3.08 (DELETE dernier RIB sous LCR -> 409), DELETE contact libre au M3 (pas de @@ -53,43 +53,60 @@ final class ProviderSubResourceApiTest extends AbstractProviderApiTestCase } /** - * RG-3.04 : un bloc Contact est valide des qu'AU MOINS UN champ est rempli parmi - * prenom / nom / FONCTION / telephone / email (spec § RG-3.04, ligne 926). Ici - * seul jobTitle (Fonction) est fourni -> le bloc est valide -> 201. + * RG-3.04 (aligne M1/M2) : un bloc Contact exige le prenom OU le nom. Une + * Fonction seule (sans nom ni prenom) ne suffit plus -> 422 rattachee a firstName. */ - public function testPostContactWithOnlyJobTitleReturns201(): void + public function testPostContactWithOnlyJobTitleReturns422(): void { $client = $this->createAdminClient(); $seed = $this->seedProvider('Contact JobTitle Only'); - $data = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [ - 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], - 'json' => ['jobTitle' => 'Directeur'], - ])->toArray(); - - self::assertResponseStatusCodeSame(201); - self::assertSame('Directeur', $data['jobTitle']); - } - - /** - * RG-3.04 : un bloc Contact TOTALEMENT vide (aucun champ du CHECK - * chk_provider_contact_name) est rejete avant la base -> 422 rattachee a - * firstName. Une Fonction vide (apres normalisation) ne suffit pas a valider. - */ - public function testPostContactCompletelyEmptyReturns422OnFirstNamePath(): void - { - $client = $this->createAdminClient(); - $seed = $this->seedProvider('Contact No Field'); - $response = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [ 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], - 'json' => ['jobTitle' => ' '], + 'json' => ['jobTitle' => 'Directeur'], ]); self::assertResponseStatusCodeSame(422); self::assertArrayHasKey('firstName', $this->violationsByPath($response->toArray(false))); } + /** + * RG-3.04 : un bloc Contact sans prenom NI nom (meme avec d'autres champs ou + * apres normalisation des chaines vides) est rejete avant la base -> 422 + * rattachee a firstName (double garde CHECK chk_provider_contact_name). + */ + public function testPostContactWithoutNameReturns422OnFirstNamePath(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedProvider('Contact No Name'); + + $response = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [ + 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], + // Email + telephone fournis mais ni prenom ni nom -> invalide (RG-3.04). + 'json' => ['email' => 'contact@acme.fr', 'phonePrimary' => '0612345678'], + ]); + + self::assertResponseStatusCodeSame(422); + self::assertArrayHasKey('firstName', $this->violationsByPath($response->toArray(false))); + } + + /** + * RG-3.04 : le prenom SEUL (sans nom) suffit a valider le contact -> 201. + */ + public function testPostContactWithOnlyFirstNameReturns201(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedProvider('Contact FirstName Only'); + + $data = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [ + 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], + 'json' => ['firstName' => 'Jean'], + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + self::assertSame('Jean', $data['firstName']); + } + public function testPostContactOnMissingProviderReturns404(): void { $client = $this->createAdminClient();