Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a83adc00a | |||
| c76c447aa2 |
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.119'
|
app.version: '0.1.120'
|
||||||
|
|||||||
@@ -390,9 +390,32 @@
|
|||||||
},
|
},
|
||||||
"tab": {
|
"tab": {
|
||||||
"contact": "Contact",
|
"contact": "Contact",
|
||||||
|
"contacts": "Contacts",
|
||||||
"address": "Adresse",
|
"address": "Adresse",
|
||||||
|
"reports": "Rapports",
|
||||||
|
"exchanges": "Échanges",
|
||||||
"accounting": "Comptabilité"
|
"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": {
|
"form": {
|
||||||
"title": "Ajouter un prestataire",
|
"title": "Ajouter un prestataire",
|
||||||
"back": "Précédent",
|
"back": "Précédent",
|
||||||
@@ -404,6 +427,7 @@
|
|||||||
"sites": "Site"
|
"sites": "Site"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
"nameRequired": "Le nom du prestataire est obligatoire.",
|
||||||
"siteRequired": "Sélectionnez au moins un site.",
|
"siteRequired": "Sélectionnez au moins un site.",
|
||||||
"categoryRequired": "Sélectionnez au moins une catégorie."
|
"categoryRequired": "Sélectionnez au moins une catégorie."
|
||||||
},
|
},
|
||||||
@@ -459,7 +483,10 @@
|
|||||||
"error": "Une erreur est survenue. Réessayez.",
|
"error": "Une erreur est survenue. Réessayez.",
|
||||||
"exportError": "L'export du répertoire prestataires a échoué. Réessayez.",
|
"exportError": "L'export du répertoire prestataires a échoué. Réessayez.",
|
||||||
"createSuccess": "Prestataire créé avec succès",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -69,14 +69,14 @@ describe('useProviderForm', () => {
|
|||||||
permState.accountingManage = false
|
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()
|
const form = useProviderForm()
|
||||||
form.main.companyName = 'Maintenance Pro'
|
|
||||||
|
|
||||||
const created = await form.submitMain()
|
const created = await form.submitMain()
|
||||||
|
|
||||||
expect(created).toBe(false)
|
expect(created).toBe(false)
|
||||||
expect(mockPost).not.toHaveBeenCalled()
|
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.sites).toBe('technique.providers.form.errors.siteRequired')
|
||||||
expect(form.mainErrors.errors.categories).toBe('technique.providers.form.errors.categoryRequired')
|
expect(form.mainErrors.errors.categories).toBe('technique.providers.form.errors.categoryRequired')
|
||||||
expect(form.mainLocked.value).toBe(false)
|
expect(form.mainLocked.value).toBe(false)
|
||||||
@@ -122,18 +122,17 @@ describe('useProviderForm', () => {
|
|||||||
expect(form.unlockedIndex.value).toBe(0)
|
expect(form.unlockedIndex.value).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('omet companyName vide du payload (laisse la 422 NotBlank back mordre)', async () => {
|
it('front : nom vide/espaces -> erreur inline sur companyName, pas de POST', async () => {
|
||||||
mockPost.mockResolvedValueOnce({ id: 1, companyName: null })
|
|
||||||
const form = useProviderForm()
|
const form = useProviderForm()
|
||||||
form.main.companyName = ' '
|
form.main.companyName = ' '
|
||||||
form.main.categoryIris = [CAT_MAINT]
|
form.main.categoryIris = [CAT_MAINT]
|
||||||
form.main.siteIris = [SITE_86]
|
form.main.siteIris = [SITE_86]
|
||||||
|
|
||||||
await form.submitMain()
|
const created = await form.submitMain()
|
||||||
|
|
||||||
const body = (mockPost.mock.calls[0] ?? [])[1] as Record<string, unknown>
|
expect(created).toBe(false)
|
||||||
expect(body).not.toHaveProperty('companyName')
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
expect(body).toEqual({ categories: [CAT_MAINT], sites: [SITE_86] })
|
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 () => {
|
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()
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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<ProviderDetail | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref(false)
|
||||||
|
|
||||||
|
/** Recupere le detail complet (embed contacts/adresses/ribs + comptabilite). */
|
||||||
|
function fetchDetail(): Promise<ProviderDetail> {
|
||||||
|
return api.get<ProviderDetail>(
|
||||||
|
`/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<void> {
|
||||||
|
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<void> {
|
||||||
|
await api.patch(`/providers/${id}`, { isArchived }, { toast: false })
|
||||||
|
provider.value = await fetchDetail()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
load,
|
||||||
|
archive: () => setArchived(true),
|
||||||
|
restore: () => setArchived(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
buildProviderContactPayload,
|
buildProviderContactPayload,
|
||||||
isProviderContactBlank,
|
isProviderContactBlank,
|
||||||
|
isProviderContactNamed,
|
||||||
} from '~/modules/technique/utils/forms/providerContact'
|
} from '~/modules/technique/utils/forms/providerContact'
|
||||||
import {
|
import {
|
||||||
buildProviderAddressPayload,
|
buildProviderAddressPayload,
|
||||||
@@ -91,6 +92,9 @@ export function useProviderForm() {
|
|||||||
const activeTab = ref<string>('contact')
|
const activeTab = ref<string>('contact')
|
||||||
// Onglets valides (passent en lecture seule).
|
// Onglets valides (passent en lecture seule).
|
||||||
const validated = reactive<Record<string, boolean>>({})
|
const validated = reactive<Record<string, boolean>>({})
|
||||||
|
// 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 {
|
function isValidated(key: string): boolean {
|
||||||
return validated[key] === true
|
return validated[key] === true
|
||||||
@@ -108,6 +112,10 @@ export function useProviderForm() {
|
|||||||
*/
|
*/
|
||||||
function validateMainFront(): boolean {
|
function validateMainFront(): boolean {
|
||||||
let valid = true
|
let valid = true
|
||||||
|
if (!main.companyName?.trim()) {
|
||||||
|
mainErrors.setError('companyName', t('technique.providers.form.errors.nameRequired'))
|
||||||
|
valid = false
|
||||||
|
}
|
||||||
if (main.siteIris.length === 0) {
|
if (main.siteIris.length === 0) {
|
||||||
mainErrors.setError('sites', t('technique.providers.form.errors.siteRequired'))
|
mainErrors.setError('sites', t('technique.providers.form.errors.siteRequired'))
|
||||||
valid = false
|
valid = false
|
||||||
@@ -192,12 +200,55 @@ export function useProviderForm() {
|
|||||||
await api.patch(`/providers/${providerId.value}`, payload, { toast: false })
|
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<boolean> {
|
||||||
|
if (providerId.value === null || mainSubmitting.value) return false
|
||||||
|
mainErrors.clearErrors()
|
||||||
|
if (!validateMainFront()) return false
|
||||||
|
|
||||||
|
mainSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const updated = await api.patch<ProviderMainResponse>(
|
||||||
|
`/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
|
* 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
|
* l'onglet suivant. Retourne true si c'etait le dernier onglet du flux
|
||||||
* (creation terminee), false sinon.
|
* (creation terminee), false sinon.
|
||||||
*/
|
*/
|
||||||
function completeTab(key: string): boolean {
|
function completeTab(key: string): boolean {
|
||||||
|
// En modification : navigation libre, l'onglet reste editable apres validation.
|
||||||
|
if (editMode.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
validated[key] = true
|
validated[key] = true
|
||||||
const index = tabIndex(key)
|
const index = tabIndex(key)
|
||||||
const next = tabKeys.value[index + 1]
|
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.
|
// Erreurs 422 par ligne (alignees sur l'index du v-for), peuplees par submitRows.
|
||||||
const contactErrors = ref<Record<string, string>[]>([])
|
const contactErrors = ref<Record<string, string>[]>([])
|
||||||
|
|
||||||
// « + 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 canAddContact = computed(() => {
|
||||||
const last = contacts.value[contacts.value.length - 1]
|
const last = contacts.value[contacts.value.length - 1]
|
||||||
return last !== undefined && !isProviderContactBlank(last)
|
return last !== undefined && isProviderContactNamed(last)
|
||||||
})
|
})
|
||||||
|
|
||||||
function addContact(): void {
|
function addContact(): void {
|
||||||
@@ -521,6 +573,7 @@ export function useProviderForm() {
|
|||||||
activeTab,
|
activeTab,
|
||||||
unlockedIndex,
|
unlockedIndex,
|
||||||
validated,
|
validated,
|
||||||
|
editMode,
|
||||||
isValidated,
|
isValidated,
|
||||||
// contacts
|
// contacts
|
||||||
contacts,
|
contacts,
|
||||||
@@ -551,6 +604,7 @@ export function useProviderForm() {
|
|||||||
validateMainFront,
|
validateMainFront,
|
||||||
buildMainPayload,
|
buildMainPayload,
|
||||||
submitMain,
|
submitMain,
|
||||||
|
updateMain,
|
||||||
patchProvider,
|
patchProvider,
|
||||||
completeTab,
|
completeTab,
|
||||||
submitRows,
|
submitRows,
|
||||||
|
|||||||
@@ -0,0 +1,538 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- En-tete : retour consultation + nom du prestataire. -->
|
||||||
|
<div class="flex items-center gap-3 pt-11">
|
||||||
|
<MalioButtonIcon
|
||||||
|
icon="mdi:arrow-left-bold"
|
||||||
|
icon-size="24"
|
||||||
|
variant="ghost"
|
||||||
|
v-bind="{ ariaLabel: t('technique.providers.edit.back') }"
|
||||||
|
@click="goBack"
|
||||||
|
/>
|
||||||
|
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Etats de chargement / introuvable. -->
|
||||||
|
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('technique.providers.edit.loading') }}</p>
|
||||||
|
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('technique.providers.edit.notFound') }}</p>
|
||||||
|
|
||||||
|
<template v-else-if="provider">
|
||||||
|
<!-- ── Bloc principal (pre-rempli, editable si `manage`) ──────────── -->
|
||||||
|
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="main.companyName"
|
||||||
|
:label="t('technique.providers.form.main.companyName')"
|
||||||
|
:required="true"
|
||||||
|
:readonly="businessReadonly"
|
||||||
|
:error="mainErrors.errors.companyName"
|
||||||
|
/>
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="main.categoryIris"
|
||||||
|
:options="referentials.categories.value"
|
||||||
|
:label="t('technique.providers.form.main.categories')"
|
||||||
|
:display-tag="true"
|
||||||
|
:readonly="businessReadonly"
|
||||||
|
:required="true"
|
||||||
|
:error="mainErrors.errors.categories"
|
||||||
|
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
||||||
|
/>
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="main.siteIris"
|
||||||
|
:options="referentials.sites.value"
|
||||||
|
:label="t('technique.providers.form.main.sites')"
|
||||||
|
:display-tag="true"
|
||||||
|
:readonly="businessReadonly"
|
||||||
|
:required="true"
|
||||||
|
:error="mainErrors.errors.sites"
|
||||||
|
@update:model-value="(v: (string | number)[]) => main.siteIris = v.map(String)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!businessReadonly" class="mt-12 flex justify-center">
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('technique.providers.edit.save')"
|
||||||
|
:disabled="mainSubmitting"
|
||||||
|
@click="onUpdateMain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Onglets : navigation LIBRE, edition independante par onglet ──── -->
|
||||||
|
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
|
||||||
|
<!-- Onglet Contact -->
|
||||||
|
<template #contact>
|
||||||
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
|
<ProviderContactBlock
|
||||||
|
v-for="(contact, index) in contacts"
|
||||||
|
:key="index"
|
||||||
|
:model-value="contact"
|
||||||
|
:removable="index > 0"
|
||||||
|
:readonly="businessReadonly"
|
||||||
|
:errors="contactErrors[index]"
|
||||||
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
|
@remove="askRemoveContact(index)"
|
||||||
|
/>
|
||||||
|
<div v-if="!businessReadonly" class="flex justify-center gap-6">
|
||||||
|
<MalioButton
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="mdi:add-bold"
|
||||||
|
icon-position="left"
|
||||||
|
:label="t('technique.providers.form.contact.add')"
|
||||||
|
:disabled="!canAddContact"
|
||||||
|
@click="addContact"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('technique.providers.edit.save')"
|
||||||
|
:disabled="tabSubmitting"
|
||||||
|
@click="onSubmitContacts"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Onglet Adresse -->
|
||||||
|
<template #address>
|
||||||
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
|
<ProviderAddressBlock
|
||||||
|
v-for="(address, index) in addresses"
|
||||||
|
:key="index"
|
||||||
|
:model-value="address"
|
||||||
|
:category-options="referentials.categories.value"
|
||||||
|
:site-options="referentials.sites.value"
|
||||||
|
:contact-options="contactOptions"
|
||||||
|
:country-options="countryOptions"
|
||||||
|
:removable="index > 0"
|
||||||
|
:readonly="businessReadonly"
|
||||||
|
:errors="addressErrors[index]"
|
||||||
|
@update:model-value="(v) => addresses[index] = v"
|
||||||
|
@remove="askRemoveAddress(index)"
|
||||||
|
@degraded="onAddressDegraded"
|
||||||
|
/>
|
||||||
|
<div v-if="!businessReadonly" class="flex justify-center gap-6">
|
||||||
|
<MalioButton
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="mdi:add-bold"
|
||||||
|
icon-position="left"
|
||||||
|
:label="t('technique.providers.form.address.add')"
|
||||||
|
:disabled="!canAddAddress"
|
||||||
|
@click="addAddress"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('technique.providers.edit.save')"
|
||||||
|
:disabled="tabSubmitting"
|
||||||
|
@click="onSubmitAddresses"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Onglet Comptabilite (present si accounting.view ; editable si manage). -->
|
||||||
|
<template v-if="canAccountingView" #accounting>
|
||||||
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
|
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||||
|
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="accounting.siren"
|
||||||
|
:label="t('technique.providers.form.accounting.siren')"
|
||||||
|
:mask="SIREN_MASK"
|
||||||
|
:readonly="accountingReadonly"
|
||||||
|
:required="true"
|
||||||
|
:error="accountingErrors.errors.siren"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="accounting.accountNumber"
|
||||||
|
:label="t('technique.providers.form.accounting.accountNumber')"
|
||||||
|
:readonly="accountingReadonly"
|
||||||
|
:required="true"
|
||||||
|
:error="accountingErrors.errors.accountNumber"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="accounting.tvaModeIri"
|
||||||
|
:options="referentials.tvaModes.value"
|
||||||
|
:label="t('technique.providers.form.accounting.tvaMode')"
|
||||||
|
:readonly="accountingReadonly"
|
||||||
|
empty-option-label=""
|
||||||
|
:required="true"
|
||||||
|
:error="accountingErrors.errors.tvaMode"
|
||||||
|
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="accounting.nTva"
|
||||||
|
:label="t('technique.providers.form.accounting.nTva')"
|
||||||
|
:readonly="accountingReadonly"
|
||||||
|
:required="true"
|
||||||
|
:error="accountingErrors.errors.nTva"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="accounting.paymentDelayIri"
|
||||||
|
:options="referentials.paymentDelays.value"
|
||||||
|
:label="t('technique.providers.form.accounting.paymentDelay')"
|
||||||
|
:readonly="accountingReadonly"
|
||||||
|
empty-option-label=""
|
||||||
|
:required="true"
|
||||||
|
:error="accountingErrors.errors.paymentDelay"
|
||||||
|
@update:model-value="(v: string | number | null) => accounting.paymentDelayIri = v === null ? null : String(v)"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="accounting.paymentTypeIri"
|
||||||
|
:options="referentials.paymentTypes.value"
|
||||||
|
:label="t('technique.providers.form.accounting.paymentType')"
|
||||||
|
:readonly="accountingReadonly"
|
||||||
|
empty-option-label=""
|
||||||
|
:required="true"
|
||||||
|
:error="accountingErrors.errors.paymentType"
|
||||||
|
@update:model-value="onPaymentTypeChange"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
v-if="isBankRequired"
|
||||||
|
:model-value="accounting.bankIri"
|
||||||
|
:options="referentials.banks.value"
|
||||||
|
:label="t('technique.providers.form.accounting.bank')"
|
||||||
|
:readonly="accountingReadonly"
|
||||||
|
empty-option-label=""
|
||||||
|
:required="true"
|
||||||
|
:error="accountingErrors.errors.bank"
|
||||||
|
@update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Blocs RIB — affiches uniquement si type de reglement = LCR (RG-3.08). -->
|
||||||
|
<div
|
||||||
|
v-for="(rib, index) in visibleRibs"
|
||||||
|
:key="index"
|
||||||
|
class="relative bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||||
|
>
|
||||||
|
<MalioButtonIcon
|
||||||
|
v-if="!accountingReadonly && visibleRibs.length > 1"
|
||||||
|
icon="mdi:delete-outline"
|
||||||
|
variant="ghost"
|
||||||
|
button-class="absolute top-3 right-3"
|
||||||
|
v-bind="{ ariaLabel: t('technique.providers.form.accounting.removeRib') }"
|
||||||
|
@click="askRemoveRib(index)"
|
||||||
|
/>
|
||||||
|
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="rib.label"
|
||||||
|
:label="t('technique.providers.form.accounting.ribLabel')"
|
||||||
|
:readonly="accountingReadonly"
|
||||||
|
:required="true"
|
||||||
|
:error="ribErrors[index]?.label"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="rib.bic"
|
||||||
|
:label="t('technique.providers.form.accounting.ribBic')"
|
||||||
|
:readonly="accountingReadonly"
|
||||||
|
:required="true"
|
||||||
|
:error="ribErrors[index]?.bic"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="rib.iban"
|
||||||
|
:label="t('technique.providers.form.accounting.ribIban')"
|
||||||
|
:readonly="accountingReadonly"
|
||||||
|
:required="true"
|
||||||
|
:error="ribErrors[index]?.iban"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!accountingReadonly" class="flex justify-center gap-6">
|
||||||
|
<MalioButton
|
||||||
|
v-if="isRibRequired"
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="mdi:add-bold"
|
||||||
|
icon-position="left"
|
||||||
|
:label="t('technique.providers.form.accounting.addRib')"
|
||||||
|
:disabled="!canAddRib"
|
||||||
|
@click="addRib"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('technique.providers.edit.save')"
|
||||||
|
:disabled="tabSubmitting"
|
||||||
|
@click="onSubmitAccounting"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</MalioTabList>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Modal de confirmation generique (suppression contact / adresse / RIB). -->
|
||||||
|
<MalioModal v-model="confirmModal.open" modal-class="max-w-md">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold">{{ t('technique.providers.form.confirmDelete.title') }}</h2>
|
||||||
|
</template>
|
||||||
|
<p>{{ confirmModal.message }}</p>
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
variant="secondary"
|
||||||
|
button-class="flex-1"
|
||||||
|
:label="t('technique.providers.form.confirmDelete.cancel')"
|
||||||
|
@click="confirmModal.open = false"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="danger"
|
||||||
|
button-class="flex-1"
|
||||||
|
:label="t('technique.providers.form.confirmDelete.confirm')"
|
||||||
|
@click="runConfirm"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MalioModal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useProvider } from '~/modules/technique/composables/useProvider'
|
||||||
|
import { useProviderReferentials, type RefOption } from '~/modules/technique/composables/useProviderReferentials'
|
||||||
|
import { useProviderForm } from '~/modules/technique/composables/useProviderForm'
|
||||||
|
import {
|
||||||
|
canEditProvider,
|
||||||
|
irisOf,
|
||||||
|
mapAccountingDraft,
|
||||||
|
mapAddressToDraft,
|
||||||
|
mapContactToDraft,
|
||||||
|
mapRibToDraft,
|
||||||
|
paymentTypeCodeOf,
|
||||||
|
} from '~/modules/technique/utils/forms/providerDetail'
|
||||||
|
import {
|
||||||
|
isBankRequiredForPaymentType,
|
||||||
|
isRibRequiredForPaymentType,
|
||||||
|
} from '~/modules/technique/utils/forms/providerAccounting'
|
||||||
|
import {
|
||||||
|
emptyProviderAddress,
|
||||||
|
emptyProviderContact,
|
||||||
|
emptyProviderRib,
|
||||||
|
} from '~/modules/technique/types/providerForm'
|
||||||
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
|
|
||||||
|
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
|
||||||
|
const SIREN_MASK = '#########'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
|
const { can, canAny } = usePermissions()
|
||||||
|
|
||||||
|
const providerId = route.params.id as string
|
||||||
|
|
||||||
|
// Acces : l'edition exige `manage` OU `accounting.manage` (le role Compta edite
|
||||||
|
// son onglet). Sinon retour consultation.
|
||||||
|
if (!canEditProvider(canAny)) {
|
||||||
|
await navigateTo(`/providers/${providerId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const businessReadonly = computed(() => !can('technique.providers.manage'))
|
||||||
|
|
||||||
|
const referentials = useProviderReferentials()
|
||||||
|
const { provider, loading, error, load } = useProvider(providerId)
|
||||||
|
|
||||||
|
const {
|
||||||
|
main,
|
||||||
|
providerId: formProviderId,
|
||||||
|
mainErrors,
|
||||||
|
mainSubmitting,
|
||||||
|
tabSubmitting,
|
||||||
|
editMode,
|
||||||
|
canAccountingView,
|
||||||
|
tabKeys,
|
||||||
|
activeTab,
|
||||||
|
contacts,
|
||||||
|
contactErrors,
|
||||||
|
canAddContact,
|
||||||
|
addContact,
|
||||||
|
removeContact,
|
||||||
|
submitContacts,
|
||||||
|
addresses,
|
||||||
|
addressErrors,
|
||||||
|
canAddAddress,
|
||||||
|
addAddress,
|
||||||
|
removeAddress,
|
||||||
|
submitAddresses,
|
||||||
|
accounting,
|
||||||
|
ribs,
|
||||||
|
accountingErrors,
|
||||||
|
ribErrors,
|
||||||
|
accountingReadonly,
|
||||||
|
setPaymentType,
|
||||||
|
canAddRib,
|
||||||
|
addRib,
|
||||||
|
removeRib,
|
||||||
|
submitAccounting,
|
||||||
|
updateMain,
|
||||||
|
} = useProviderForm()
|
||||||
|
|
||||||
|
// Modification : navigation libre + pas de verrouillage a la validation.
|
||||||
|
editMode.value = true
|
||||||
|
activeTab.value = 'contact'
|
||||||
|
|
||||||
|
const headerTitle = computed(() => provider.value?.companyName || t('technique.providers.edit.title'))
|
||||||
|
useHead({ title: t('technique.providers.edit.title') })
|
||||||
|
|
||||||
|
// ── Onglets (navigation libre ; Comptabilite si accounting.view) ───────────────
|
||||||
|
const TAB_ICONS: Record<string, string> = {
|
||||||
|
contact: 'mdi:account-box-plus-outline',
|
||||||
|
address: 'mdi:map-marker-outline',
|
||||||
|
accounting: 'mdi:bank-circle-outline',
|
||||||
|
}
|
||||||
|
const tabs = computed(() => tabKeys.value.map(key => ({
|
||||||
|
key,
|
||||||
|
label: t(`technique.providers.tab.${key}`),
|
||||||
|
icon: TAB_ICONS[key],
|
||||||
|
})))
|
||||||
|
|
||||||
|
/** Pre-remplit les brouillons depuis la SEULE reponse detail. */
|
||||||
|
function prefill(): void {
|
||||||
|
const d = provider.value
|
||||||
|
if (!d) return
|
||||||
|
|
||||||
|
// Indispensable : pilote les URLs des PATCH/POST par onglet (sinon les submits no-op).
|
||||||
|
formProviderId.value = d.id
|
||||||
|
|
||||||
|
main.companyName = d.companyName ?? null
|
||||||
|
main.categoryIris = irisOf(d.categories)
|
||||||
|
main.siteIris = irisOf(d.sites)
|
||||||
|
|
||||||
|
const mappedContacts = (d.contacts ?? []).map(mapContactToDraft)
|
||||||
|
contacts.value = mappedContacts.length > 0 ? mappedContacts : [emptyProviderContact()]
|
||||||
|
|
||||||
|
const mappedAddresses = (d.addresses ?? []).map(mapAddressToDraft)
|
||||||
|
addresses.value = mappedAddresses.length > 0 ? mappedAddresses : [emptyProviderAddress()]
|
||||||
|
|
||||||
|
if (canAccountingView.value) {
|
||||||
|
Object.assign(accounting, mapAccountingDraft(d))
|
||||||
|
ribs.value = (d.ribs ?? []).map(mapRibToDraft)
|
||||||
|
// Garantit un bloc RIB visible si le type de reglement est LCR.
|
||||||
|
if (isRibRequiredForPaymentType(paymentTypeCodeOf(d.paymentType)) && ribs.value.length === 0) {
|
||||||
|
ribs.value.push(emptyProviderRib())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Comptabilite : RG-3.07 / RG-3.08 pilotees par le code du type de reglement ──
|
||||||
|
const selectedPaymentTypeCode = computed(() =>
|
||||||
|
referentials.paymentTypes.value.find(p => p.value === accounting.paymentTypeIri)?.code ?? null,
|
||||||
|
)
|
||||||
|
const isBankRequired = computed(() => isBankRequiredForPaymentType(selectedPaymentTypeCode.value))
|
||||||
|
const isRibRequired = computed(() => isRibRequiredForPaymentType(selectedPaymentTypeCode.value))
|
||||||
|
const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
|
||||||
|
|
||||||
|
function onPaymentTypeChange(value: string | number | null): void {
|
||||||
|
const iri = value === null ? null : String(value)
|
||||||
|
const code = referentials.paymentTypes.value.find(p => p.value === iri)?.code ?? null
|
||||||
|
setPaymentType(iri, isBankRequiredForPaymentType(code), isRibRequiredForPaymentType(code))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Options adresses ──────────────────────────────────────────────────────────
|
||||||
|
const contactOptions = computed<RefOption[]>(() =>
|
||||||
|
contacts.value
|
||||||
|
.filter(c => c.iri !== null)
|
||||||
|
.map(c => ({
|
||||||
|
value: c.iri as string,
|
||||||
|
label: [c.firstName, c.lastName].filter(Boolean).join(' ') || (c.email ?? ''),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const countryOptions = computed<RefOption[]>(() => {
|
||||||
|
const list = referentials.countries.value
|
||||||
|
return list.some(c => c.value === 'France')
|
||||||
|
? list
|
||||||
|
: [{ value: 'France', label: 'France' }, ...list]
|
||||||
|
})
|
||||||
|
|
||||||
|
const addressDegradedNotified = ref(false)
|
||||||
|
function onAddressDegraded(): void {
|
||||||
|
if (addressDegradedNotified.value) return
|
||||||
|
addressDegradedNotified.value = true
|
||||||
|
toast.warning({
|
||||||
|
title: t('technique.providers.toast.error'),
|
||||||
|
message: t('technique.providers.form.address.degraded'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Navigation + helpers ──────────────────────────────────────────────────────
|
||||||
|
function goBack(): void {
|
||||||
|
router.push(`/providers/${providerId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiErrorMessage(err: unknown): string {
|
||||||
|
const data = (err as { response?: { _data?: unknown } })?.response?._data
|
||||||
|
return extractApiErrorMessage(data) || t('technique.providers.toast.error')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PATCH du bloc principal (groupe provider:write:main). */
|
||||||
|
async function onUpdateMain(): Promise<void> {
|
||||||
|
if (await updateMain()) {
|
||||||
|
toast.success({ title: t('technique.providers.toast.updateSuccess') })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmitContacts(): Promise<void> {
|
||||||
|
const ok = await submitContacts(err => toast.error({
|
||||||
|
title: t('technique.providers.toast.error'),
|
||||||
|
message: apiErrorMessage(err),
|
||||||
|
}))
|
||||||
|
if (ok) toast.success({ title: t('technique.providers.toast.updateSuccess') })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmitAddresses(): Promise<void> {
|
||||||
|
const ok = await submitAddresses(err => toast.error({
|
||||||
|
title: t('technique.providers.toast.error'),
|
||||||
|
message: apiErrorMessage(err),
|
||||||
|
}))
|
||||||
|
if (ok) toast.success({ title: t('technique.providers.toast.updateSuccess') })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmitAccounting(): Promise<void> {
|
||||||
|
const ok = await submitAccounting(
|
||||||
|
isBankRequired.value,
|
||||||
|
isRibRequired.value,
|
||||||
|
err => toast.error({ title: t('technique.providers.toast.error'), message: apiErrorMessage(err) }),
|
||||||
|
)
|
||||||
|
if (ok) toast.success({ title: t('technique.providers.toast.updateSuccess') })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Modal de confirmation generique ───────────────────────────────────────────
|
||||||
|
const confirmModal = reactive({
|
||||||
|
open: false,
|
||||||
|
message: '',
|
||||||
|
action: null as null | (() => void),
|
||||||
|
})
|
||||||
|
|
||||||
|
function askConfirm(message: string, action: () => void): void {
|
||||||
|
confirmModal.message = message
|
||||||
|
confirmModal.action = action
|
||||||
|
confirmModal.open = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function runConfirm(): void {
|
||||||
|
confirmModal.action?.()
|
||||||
|
confirmModal.action = null
|
||||||
|
confirmModal.open = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function askRemoveContact(index: number): void {
|
||||||
|
askConfirm(t('technique.providers.form.confirmDelete.contact'), () => removeContact(index))
|
||||||
|
}
|
||||||
|
|
||||||
|
function askRemoveAddress(index: number): void {
|
||||||
|
askConfirm(t('technique.providers.form.confirmDelete.address'), () => removeAddress(index))
|
||||||
|
}
|
||||||
|
|
||||||
|
function askRemoveRib(index: number): void {
|
||||||
|
askConfirm(t('technique.providers.form.confirmDelete.rib'), () => removeRib(index))
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
referentials.loadMain().catch(() => {})
|
||||||
|
if (canAccountingView.value) {
|
||||||
|
referentials.loadAccounting().catch(() => {})
|
||||||
|
}
|
||||||
|
await load()
|
||||||
|
prefill()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- En-tete : retour repertoire + nom du prestataire + actions. -->
|
||||||
|
<div class="flex items-center gap-3 pt-11">
|
||||||
|
<MalioButtonIcon
|
||||||
|
icon="mdi:arrow-left-bold"
|
||||||
|
icon-size="24"
|
||||||
|
variant="ghost"
|
||||||
|
v-bind="{ ariaLabel: t('technique.providers.consultation.back') }"
|
||||||
|
@click="goBack"
|
||||||
|
/>
|
||||||
|
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
|
||||||
|
|
||||||
|
<div class="ml-auto flex items-center gap-12">
|
||||||
|
<MalioButton
|
||||||
|
v-if="canEdit"
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="mdi:pencil-outline"
|
||||||
|
icon-position="left"
|
||||||
|
:label="t('technique.providers.action.edit')"
|
||||||
|
@click="goEdit"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
v-if="showArchive"
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="mdi:archive-arrow-down-outline"
|
||||||
|
icon-position="left"
|
||||||
|
:label="t('technique.providers.action.archive')"
|
||||||
|
@click="askToggleArchive"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
v-if="showRestore"
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="mdi:archive-arrow-up-outline"
|
||||||
|
icon-position="left"
|
||||||
|
:label="t('technique.providers.action.restore')"
|
||||||
|
@click="askToggleArchive"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Etats de chargement / introuvable. -->
|
||||||
|
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('technique.providers.consultation.loading') }}</p>
|
||||||
|
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('technique.providers.consultation.notFound') }}</p>
|
||||||
|
|
||||||
|
<template v-else-if="provider">
|
||||||
|
<!-- ── Bloc principal (lecture seule) ─────────────────────────────── -->
|
||||||
|
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="provider.companyName"
|
||||||
|
:label="t('technique.providers.form.main.companyName')"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="mainCategoryIris"
|
||||||
|
:options="mainCategoryOptions"
|
||||||
|
:label="t('technique.providers.form.main.categories')"
|
||||||
|
:display-tag="true"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="mainSiteIris"
|
||||||
|
:options="mainSiteOptions"
|
||||||
|
:label="t('technique.providers.form.main.sites')"
|
||||||
|
:display-tag="true"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Onglets (navigation libre, tout en lecture seule) ──────────── -->
|
||||||
|
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
|
||||||
|
<!-- Onglet Contacts -->
|
||||||
|
<template #contacts>
|
||||||
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
|
<ProviderContactBlock
|
||||||
|
v-for="(contact, index) in contacts"
|
||||||
|
:key="index"
|
||||||
|
:model-value="contact"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Onglet Adresse -->
|
||||||
|
<template #address>
|
||||||
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
|
<ProviderAddressBlock
|
||||||
|
v-for="(view, index) in addressViews"
|
||||||
|
:key="index"
|
||||||
|
:model-value="view.draft"
|
||||||
|
:category-options="view.categoryOptions"
|
||||||
|
:site-options="view.siteOptions"
|
||||||
|
:contact-options="contactOptions"
|
||||||
|
:country-options="countryOptionsFor(view.draft.country)"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Onglets placeholder « A venir » (comme les autres modules). -->
|
||||||
|
<template #reports><ComingSoonPlaceholder /></template>
|
||||||
|
<template #exchanges><ComingSoonPlaceholder /></template>
|
||||||
|
|
||||||
|
<!-- Onglet Comptabilite (present uniquement si accounting.view). -->
|
||||||
|
<template v-if="canAccountingView" #accounting>
|
||||||
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
|
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||||
|
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
|
<MalioInputText :model-value="accounting.siren" :label="t('technique.providers.form.accounting.siren')" readonly />
|
||||||
|
<MalioInputText :model-value="accounting.accountNumber" :label="t('technique.providers.form.accounting.accountNumber')" readonly />
|
||||||
|
<MalioSelect :model-value="accounting.tvaModeIri" :options="tvaModeOptions" :label="t('technique.providers.form.accounting.tvaMode')" readonly empty-option-label="" />
|
||||||
|
<MalioInputText :model-value="accounting.nTva" :label="t('technique.providers.form.accounting.nTva')" readonly />
|
||||||
|
<MalioSelect :model-value="accounting.paymentDelayIri" :options="paymentDelayOptions" :label="t('technique.providers.form.accounting.paymentDelay')" readonly empty-option-label="" />
|
||||||
|
<MalioSelect :model-value="accounting.paymentTypeIri" :options="paymentTypeOptions" :label="t('technique.providers.form.accounting.paymentType')" readonly empty-option-label="" />
|
||||||
|
<MalioSelect v-if="isBankRequired" :model-value="accounting.bankIri" :options="bankOptions" :label="t('technique.providers.form.accounting.bank')" readonly empty-option-label="" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Blocs RIB (uniquement si type de reglement = LCR). -->
|
||||||
|
<div
|
||||||
|
v-for="(rib, index) in visibleRibs"
|
||||||
|
:key="index"
|
||||||
|
class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
|
<MalioInputText :model-value="rib.label" :label="t('technique.providers.form.accounting.ribLabel')" readonly />
|
||||||
|
<MalioInputText :model-value="rib.bic" :label="t('technique.providers.form.accounting.ribBic')" readonly />
|
||||||
|
<MalioInputText :model-value="rib.iban" :label="t('technique.providers.form.accounting.ribIban')" readonly />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</MalioTabList>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Modal de confirmation archivage / restauration. -->
|
||||||
|
<MalioModal v-model="confirmArchive.open" modal-class="max-w-md">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold">{{ confirmArchive.title }}</h2>
|
||||||
|
</template>
|
||||||
|
<p>{{ confirmArchive.message }}</p>
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
variant="secondary"
|
||||||
|
button-class="flex-1"
|
||||||
|
:label="t('technique.providers.form.confirmDelete.cancel')"
|
||||||
|
@click="confirmArchive.open = false"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="danger"
|
||||||
|
button-class="flex-1"
|
||||||
|
:label="confirmArchive.confirmLabel"
|
||||||
|
@click="runToggleArchive"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MalioModal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useProvider } from '~/modules/technique/composables/useProvider'
|
||||||
|
import {
|
||||||
|
canEditProvider,
|
||||||
|
categoryOptionsOf,
|
||||||
|
contactOptionsOf,
|
||||||
|
irisOf,
|
||||||
|
mapAccountingDraft,
|
||||||
|
mapAddressToDraft,
|
||||||
|
mapContactToDraft,
|
||||||
|
mapRibToDraft,
|
||||||
|
paymentTypeCodeOf,
|
||||||
|
referentialOptionOf,
|
||||||
|
showArchiveAction,
|
||||||
|
showRestoreAction,
|
||||||
|
siteOptionsOf,
|
||||||
|
} from '~/modules/technique/utils/forms/providerDetail'
|
||||||
|
import { isBankRequiredForPaymentType, isRibRequiredForPaymentType } from '~/modules/technique/utils/forms/providerAccounting'
|
||||||
|
import { emptyProviderAddress, emptyProviderContact } from '~/modules/technique/types/providerForm'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
|
const { can, canAny } = usePermissions()
|
||||||
|
|
||||||
|
const providerId = route.params.id as string
|
||||||
|
const { provider, loading, error, load, archive, restore } = useProvider(providerId)
|
||||||
|
|
||||||
|
const canAccountingView = computed(() => can('technique.providers.accounting.view'))
|
||||||
|
const canEdit = computed(() => canEditProvider(canAny))
|
||||||
|
const isArchived = computed(() => provider.value?.isArchived ?? false)
|
||||||
|
const showArchive = computed(() => showArchiveAction(can, isArchived.value))
|
||||||
|
const showRestore = computed(() => showRestoreAction(can, isArchived.value))
|
||||||
|
|
||||||
|
const headerTitle = computed(() => provider.value?.companyName || t('technique.providers.consultation.title'))
|
||||||
|
useHead({ title: t('technique.providers.consultation.title') })
|
||||||
|
|
||||||
|
// ── Onglets (ordre spec : Contacts · Adresse · Rapports · Échanges · Comptabilité) ──
|
||||||
|
const activeTab = ref('contacts')
|
||||||
|
const TAB_ICONS: Record<string, string> = {
|
||||||
|
contacts: 'mdi:account-box-plus-outline',
|
||||||
|
address: 'mdi:map-marker-outline',
|
||||||
|
reports: 'mdi:file-chart-outline',
|
||||||
|
exchanges: 'mdi:swap-horizontal',
|
||||||
|
accounting: 'mdi:bank-circle-outline',
|
||||||
|
}
|
||||||
|
const tabs = computed(() => {
|
||||||
|
const keys = ['contacts', 'address', 'reports', 'exchanges']
|
||||||
|
if (canAccountingView.value) keys.push('accounting')
|
||||||
|
return keys.map(key => ({ key, label: t(`technique.providers.tab.${key}`), icon: TAB_ICONS[key] }))
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Donnees mappees depuis la SEULE reponse detail ─────────────────────────────
|
||||||
|
const mainCategoryIris = computed(() => irisOf(provider.value?.categories))
|
||||||
|
const mainSiteIris = computed(() => irisOf(provider.value?.sites))
|
||||||
|
const mainCategoryOptions = computed(() => categoryOptionsOf(provider.value?.categories))
|
||||||
|
const mainSiteOptions = computed(() => siteOptionsOf(provider.value?.sites))
|
||||||
|
|
||||||
|
// Au moins un bloc affiche meme sans donnee (bloc vide en lecture seule, comme
|
||||||
|
// l'onglet Comptabilite et les autres modules — pas de message « Aucun … »).
|
||||||
|
const contacts = computed(() => {
|
||||||
|
const list = (provider.value?.contacts ?? []).map(mapContactToDraft)
|
||||||
|
return list.length > 0 ? list : [emptyProviderContact()]
|
||||||
|
})
|
||||||
|
// Contacts rattachables (pour resoudre les libelles des contacts lies aux adresses).
|
||||||
|
const contactOptions = computed(() => contactOptionsOf(provider.value?.contacts))
|
||||||
|
|
||||||
|
// Vue par adresse : brouillon + options propres a l'adresse (sites/categories embarques).
|
||||||
|
const addressViews = computed(() => {
|
||||||
|
const views = (provider.value?.addresses ?? []).map(address => ({
|
||||||
|
draft: mapAddressToDraft(address),
|
||||||
|
siteOptions: siteOptionsOf(address.sites),
|
||||||
|
categoryOptions: categoryOptionsOf(address.categories),
|
||||||
|
}))
|
||||||
|
return views.length > 0
|
||||||
|
? views
|
||||||
|
: [{ draft: emptyProviderAddress(), siteOptions: [], categoryOptions: [] }]
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Pays : une seule option (la valeur courante), suffisant pour l'affichage readonly. */
|
||||||
|
function countryOptionsFor(country: string): { value: string, label: string }[] {
|
||||||
|
return country ? [{ value: country, label: country }] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Comptabilite (presente uniquement si accounting.view) ──────────────────────
|
||||||
|
const accounting = computed(() => mapAccountingDraft(provider.value ?? { id: 0, '@id': '' }))
|
||||||
|
const paymentTypeCode = computed(() => paymentTypeCodeOf(provider.value?.paymentType))
|
||||||
|
const isBankRequired = computed(() => isBankRequiredForPaymentType(paymentTypeCode.value))
|
||||||
|
const isRibRequired = computed(() => isRibRequiredForPaymentType(paymentTypeCode.value))
|
||||||
|
const visibleRibs = computed(() => isRibRequired.value ? (provider.value?.ribs ?? []).map(mapRibToDraft) : [])
|
||||||
|
|
||||||
|
// Options « une entree » construites depuis l'embed (libelles role-independants).
|
||||||
|
const tvaModeOptions = computed(() => referentialOptionOf(provider.value?.tvaMode))
|
||||||
|
const paymentDelayOptions = computed(() => referentialOptionOf(provider.value?.paymentDelay))
|
||||||
|
const paymentTypeOptions = computed(() => referentialOptionOf(provider.value?.paymentType))
|
||||||
|
const bankOptions = computed(() => referentialOptionOf(provider.value?.bank))
|
||||||
|
|
||||||
|
// ── Navigation / actions ───────────────────────────────────────────────────────
|
||||||
|
function goBack(): void {
|
||||||
|
router.push('/providers')
|
||||||
|
}
|
||||||
|
|
||||||
|
function goEdit(): void {
|
||||||
|
router.push(`/providers/${providerId}/edit`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Archivage / restauration ───────────────────────────────────────────────────
|
||||||
|
const confirmArchive = reactive({
|
||||||
|
open: false,
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
confirmLabel: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
function askToggleArchive(): void {
|
||||||
|
const archiving = !isArchived.value
|
||||||
|
confirmArchive.title = archiving
|
||||||
|
? t('technique.providers.action.archive')
|
||||||
|
: t('technique.providers.action.restore')
|
||||||
|
confirmArchive.message = archiving
|
||||||
|
? t('technique.providers.consultation.confirmArchive')
|
||||||
|
: t('technique.providers.consultation.confirmRestore')
|
||||||
|
confirmArchive.confirmLabel = archiving
|
||||||
|
? t('technique.providers.action.archive')
|
||||||
|
: t('technique.providers.action.restore')
|
||||||
|
confirmArchive.open = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runToggleArchive(): Promise<void> {
|
||||||
|
const archiving = !isArchived.value
|
||||||
|
confirmArchive.open = false
|
||||||
|
try {
|
||||||
|
await (archiving ? archive() : restore())
|
||||||
|
toast.success({
|
||||||
|
title: archiving
|
||||||
|
? t('technique.providers.toast.archiveSuccess')
|
||||||
|
: t('technique.providers.toast.restoreSuccess'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// 409 a la restauration (homonyme actif) ou autre : toast generique.
|
||||||
|
toast.error({ title: t('technique.providers.toast.error') })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
<MalioButton
|
<MalioButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
:label="t('technique.providers.form.submit')"
|
:label="t('technique.providers.form.submit')"
|
||||||
:disabled="tabSubmitting"
|
:disabled="tabSubmitting || providerId === null"
|
||||||
@click="onSubmitContacts"
|
@click="onSubmitContacts"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
<MalioButton
|
<MalioButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
:label="t('technique.providers.form.submit')"
|
:label="t('technique.providers.form.submit')"
|
||||||
:disabled="tabSubmitting"
|
:disabled="tabSubmitting || providerId === null"
|
||||||
@click="onSubmitAddresses"
|
@click="onSubmitAddresses"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -251,7 +251,7 @@
|
|||||||
<MalioButton
|
<MalioButton
|
||||||
variant="primary"
|
variant="primary"
|
||||||
:label="t('technique.providers.form.submit')"
|
:label="t('technique.providers.form.submit')"
|
||||||
:disabled="tabSubmitting"
|
:disabled="tabSubmitting || providerId === null"
|
||||||
@click="onSubmitAccounting"
|
@click="onSubmitAccounting"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -314,6 +314,7 @@ const referentials = useProviderReferentials()
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
main,
|
main,
|
||||||
|
providerId,
|
||||||
mainLocked,
|
mainLocked,
|
||||||
mainSubmitting,
|
mainSubmitting,
|
||||||
mainErrors,
|
mainErrors,
|
||||||
@@ -362,15 +363,33 @@ function apiErrorMessage(error: unknown): string {
|
|||||||
return extractApiErrorMessage(data) || t('technique.providers.toast.error')
|
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 ──────────────────────────────────────────────────────────
|
// ── 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<void> {
|
async function onSubmitContacts(): Promise<void> {
|
||||||
const ok = await submitContacts(error => toast.error({
|
const ok = await submitContacts(error => toast.error({
|
||||||
title: t('technique.providers.toast.error'),
|
title: t('technique.providers.toast.error'),
|
||||||
message: apiErrorMessage(error),
|
message: apiErrorMessage(error),
|
||||||
}))
|
}))
|
||||||
if (ok) {
|
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<void> {
|
async function onSubmitAddresses(): Promise<void> {
|
||||||
const ok = await submitAddresses(error => toast.error({
|
const ok = await submitAddresses(error => toast.error({
|
||||||
title: t('technique.providers.toast.error'),
|
title: t('technique.providers.toast.error'),
|
||||||
message: apiErrorMessage(error),
|
message: apiErrorMessage(error),
|
||||||
}))
|
}))
|
||||||
if (ok) {
|
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))
|
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<void> {
|
async function onSubmitAccounting(): Promise<void> {
|
||||||
const ok = await submitAccounting(
|
const ok = await submitAccounting(
|
||||||
isBankRequired.value,
|
isBankRequired.value,
|
||||||
@@ -461,7 +480,7 @@ async function onSubmitAccounting(): Promise<void> {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if (ok) {
|
if (ok) {
|
||||||
toast.success({ title: t('technique.providers.toast.updateSuccess') })
|
onTabSaved('accounting')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
buildProviderContactPayload,
|
buildProviderContactPayload,
|
||||||
hasAtLeastOneFilledContact,
|
hasAtLeastOneFilledContact,
|
||||||
isProviderContactBlank,
|
isProviderContactBlank,
|
||||||
|
isProviderContactNamed,
|
||||||
} from '../providerContact'
|
} from '../providerContact'
|
||||||
import { emptyProviderContact } from '~/modules/technique/types/providerForm'
|
import { emptyProviderContact } from '~/modules/technique/types/providerForm'
|
||||||
|
|
||||||
@@ -34,15 +35,28 @@ describe('providerContact helpers', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('hasAtLeastOneFilledContact (RG-3.12)', () => {
|
describe('isProviderContactNamed (RG-3.04 — prenom OU nom)', () => {
|
||||||
it('false si tous les blocs sont vides', () => {
|
it('vrai avec un prenom seul ou un nom seul', () => {
|
||||||
expect(hasAtLeastOneFilledContact([emptyProviderContact(), emptyProviderContact()])).toBe(false)
|
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([
|
expect(hasAtLeastOneFilledContact([
|
||||||
emptyProviderContact(),
|
emptyProviderContact(),
|
||||||
{ ...emptyProviderContact(), email: 'a@b.fr' },
|
{ ...emptyProviderContact(), lastName: 'Dupont' },
|
||||||
])).toBe(true)
|
])).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -32,12 +32,21 @@ export function isProviderContactBlank(contact: ProviderContactFormDraft): boole
|
|||||||
].some(isFilled)
|
].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
|
* 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 {
|
export function hasAtLeastOneFilledContact(contacts: ProviderContactFormDraft[]): boolean {
|
||||||
return contacts.some(contact => !isProviderContactBlank(contact))
|
return contacts.some(isProviderContactNamed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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<HydraRef | string>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RG-3.04 (correctif) — aligne la regle de validite d'un contact prestataire sur
|
||||||
|
* le M1/M2 : au moins le PRENOM OU le NOM (et non plus « un champ quelconque parmi
|
||||||
|
* prenom/nom/fonction/telephone/email »). Remplace le CHECK chk_provider_contact_name
|
||||||
|
* et met a jour les commentaires de colonnes. La garde applicative
|
||||||
|
* (ProviderContactProcessor::validateName) est alignee dans le meme commit.
|
||||||
|
*
|
||||||
|
* Placee au namespace racine DoctrineMigrations (et non en modulaire Technique) :
|
||||||
|
* elle ALTERE une table creee par une migration racine (Version20260612100000) ;
|
||||||
|
* le tri par version au sein du meme namespace garantit qu'elle joue APRES l'init
|
||||||
|
* (cf. CLAUDE.md regle 11 — le tri cross-namespace casserait l'ordre sur base vide).
|
||||||
|
*/
|
||||||
|
final class Version20260615120000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'RG-3.04 : contact prestataire valide si prenom OU nom (alignement M1/M2) — CHECK chk_provider_contact_name.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(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)');
|
||||||
|
|
||||||
|
$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).$_$');
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Technique\Application\Validator;
|
||||||
|
|
||||||
|
use ApiPlatform\Validator\Exception\ValidationException;
|
||||||
|
use App\Module\Technique\Domain\Entity\Provider;
|
||||||
|
use Symfony\Component\Validator\ConstraintViolation;
|
||||||
|
use Symfony\Component\Validator\ConstraintViolationList;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validator metier (spec-front M3 § Onglet Comptabilite — jumeau de
|
||||||
|
* SupplierAccountingCompletenessValidator M2) : a la soumission complete de
|
||||||
|
* l'onglet Comptabilite, les six champs scalaires obligatoires doivent etre
|
||||||
|
* renseignes (SIREN, Numero de compte, Mode de TVA, N de TVA, Delai de reglement,
|
||||||
|
* Type de reglement). La banque reste conditionnelle (RG-3.07) et les RIB aussi
|
||||||
|
* (RG-3.08) : ils ne sont pas couverts ici (Assert\Callback sur l'entite Provider
|
||||||
|
* — validatePaymentTypeConsistency).
|
||||||
|
*
|
||||||
|
* Parti pris (miroir M1/M2) : colonnes nullable en base + validateur contextuel,
|
||||||
|
* plutot qu'un Assert\NotBlank sur l'entite (qui casserait le POST de l'onglet
|
||||||
|
* principal, lequel n'envoie aucun champ comptable).
|
||||||
|
*
|
||||||
|
* Invoque par le ProviderProcessor uniquement quand le payload porte les six
|
||||||
|
* champs (= une validation d'onglet), jamais sur un PATCH ciblant un seul champ.
|
||||||
|
*
|
||||||
|
* Leve une ValidationException (HTTP 422) listant chaque champ manquant, par
|
||||||
|
* coherence avec les violations Symfony rendues par API Platform (mapping inline
|
||||||
|
* front via useFormErrors, ERP-101).
|
||||||
|
*/
|
||||||
|
final class ProviderAccountingCompletenessValidator
|
||||||
|
{
|
||||||
|
public function validate(Provider $provider): void
|
||||||
|
{
|
||||||
|
// Map champ -> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-12
@@ -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 /
|
* RG-3.04 : un bloc Contact exige au moins le prenom OU le nom (aligne sur le
|
||||||
* nom / fonction / telephone principal / email est renseigne (double garde avec
|
* M1/M2 — un contact se materialise par son nom ; fonction / telephone / email
|
||||||
* le CHECK BDD chk_provider_contact_name — leve une 422 propre rattachee au
|
* seuls ne suffisent pas). Double garde avec le CHECK BDD chk_provider_contact_name
|
||||||
* champ `firstName` plutot qu'une 500 SQL). Joue apres normalisation, donc les
|
* — leve une 422 propre rattachee au champ `firstName` plutot qu'une 500 SQL.
|
||||||
* chaines vides (y compris une fonction ou un phone_secondary vides) sont deja
|
* Joue apres normalisation (les chaines vides sont deja ramenees a null).
|
||||||
* ramenees a null et ne suffisent pas a valider le bloc.
|
|
||||||
*/
|
*/
|
||||||
private function validateName(ProviderContact $contact): void
|
private function validateName(ProviderContact $contact): void
|
||||||
{
|
{
|
||||||
if (null === $contact->getFirstName()
|
if (null === $contact->getFirstName() && null === $contact->getLastName()) {
|
||||||
&& null === $contact->getLastName()
|
|
||||||
&& null === $contact->getJobTitle()
|
|
||||||
&& null === $contact->getPhonePrimary()
|
|
||||||
&& null === $contact->getEmail()) {
|
|
||||||
$violations = new ConstraintViolationList();
|
$violations = new ConstraintViolationList();
|
||||||
$violations->add(new ConstraintViolation(
|
$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,
|
null,
|
||||||
[],
|
[],
|
||||||
$contact,
|
$contact,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use ApiPlatform\State\ProcessorInterface;
|
|||||||
use ApiPlatform\Validator\Exception\ValidationException;
|
use ApiPlatform\Validator\Exception\ValidationException;
|
||||||
use App\Module\Core\Domain\Entity\User;
|
use App\Module\Core\Domain\Entity\User;
|
||||||
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
|
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
|
||||||
|
use App\Module\Technique\Application\Validator\ProviderAccountingCompletenessValidator;
|
||||||
use App\Module\Technique\Domain\Entity\Provider;
|
use App\Module\Technique\Domain\Entity\Provider;
|
||||||
use App\Shared\Domain\Contract\SiteInterface;
|
use App\Shared\Domain\Contract\SiteInterface;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
@@ -75,6 +76,15 @@ final class ProviderProcessor implements ProcessorInterface
|
|||||||
'paymentType', 'bank',
|
'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). */
|
/** Champ d'archivage (groupe provider:write:archive). */
|
||||||
private const string ARCHIVE_FIELD = 'isArchived';
|
private const string ARCHIVE_FIELD = 'isArchived';
|
||||||
|
|
||||||
@@ -102,6 +112,7 @@ final class ProviderProcessor implements ProcessorInterface
|
|||||||
private readonly Security $security,
|
private readonly Security $security,
|
||||||
private readonly RequestStack $requestStack,
|
private readonly RequestStack $requestStack,
|
||||||
private readonly EntityManagerInterface $em,
|
private readonly EntityManagerInterface $em,
|
||||||
|
private readonly ProviderAccountingCompletenessValidator $accountingValidator,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
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).
|
// deux cotes (l'etat persiste l'a deja ete).
|
||||||
$this->guardManage($data);
|
$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 {
|
try {
|
||||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
} catch (UniqueConstraintViolationException $e) {
|
} catch (UniqueConstraintViolationException $e) {
|
||||||
@@ -496,6 +511,21 @@ final class ProviderProcessor implements ProcessorInterface
|
|||||||
*
|
*
|
||||||
* @return list<string>
|
* @return list<string>
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 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
|
private function payloadKeys(): array
|
||||||
{
|
{
|
||||||
$request = $this->requestStack->getCurrentRequest();
|
$request = $this->requestStack->getCurrentRequest();
|
||||||
|
|||||||
@@ -395,12 +395,12 @@ final class ColumnCommentsCatalog
|
|||||||
],
|
],
|
||||||
|
|
||||||
'provider_contact' => [
|
'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.',
|
'id' => 'Identifiant interne auto-incremente.',
|
||||||
'provider_id' => 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire du contact.',
|
'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).',
|
'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). Au moins un champ du contact requis (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).',
|
'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_primary' => 'Telephone principal du contact — chiffres uniquement (normalisation serveur).',
|
||||||
'phone_secondary' => 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).',
|
'phone_secondary' => 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).',
|
||||||
'email' => 'Email du contact (lowercase serveur).',
|
'email' => 'Email du contact (lowercase serveur).',
|
||||||
|
|||||||
@@ -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
|
* 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
|
* Comptabilite (groupe provider:write:accounting). On asserte le code HTTP et le
|
||||||
* propertyPath de la violation (consommable par extractApiViolations cote front,
|
* propertyPath de la violation (consommable par extractApiViolations cote front,
|
||||||
* ERP-101). Jumeau de SupplierAccountingApiTest (M2), sans le bloc « completude de
|
* ERP-101). Jumeau de SupplierAccountingApiTest (M2), completude de l'onglet
|
||||||
* l'onglet » : le prestataire est minimal et n'impose pas les six scalaires
|
* INCLUSE : a la validation complete de l'onglet, les six scalaires comptables
|
||||||
* comptables (spec M3 § 3.1).
|
* sont obligatoires (spec-front M3 § Onglet Comptabilite — aligne M1/M2).
|
||||||
*
|
*
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
@@ -81,5 +81,58 @@ final class ProviderAccountingValidationTest extends AbstractProviderApiTestCase
|
|||||||
self::assertResponseStatusCodeSame(200);
|
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.
|
// violationsByPath() : helper mutualise dans AbstractProviderApiTestCase.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use App\Module\Technique\Domain\Entity\Provider;
|
|||||||
/**
|
/**
|
||||||
* Tests fonctionnels des sous-ressources Contacts / Adresses / RIB du prestataire
|
* 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
|
* (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),
|
* 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`),
|
* 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
|
* 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
|
* RG-3.04 (aligne M1/M2) : un bloc Contact exige le prenom OU le nom. Une
|
||||||
* prenom / nom / FONCTION / telephone / email (spec § RG-3.04, ligne 926). Ici
|
* Fonction seule (sans nom ni prenom) ne suffit plus -> 422 rattachee a firstName.
|
||||||
* seul jobTitle (Fonction) est fourni -> le bloc est valide -> 201.
|
|
||||||
*/
|
*/
|
||||||
public function testPostContactWithOnlyJobTitleReturns201(): void
|
public function testPostContactWithOnlyJobTitleReturns422(): void
|
||||||
{
|
{
|
||||||
$client = $this->createAdminClient();
|
$client = $this->createAdminClient();
|
||||||
$seed = $this->seedProvider('Contact JobTitle Only');
|
$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', [
|
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
|
||||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||||
'json' => ['jobTitle' => ' '],
|
'json' => ['jobTitle' => 'Directeur'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
self::assertResponseStatusCodeSame(422);
|
self::assertResponseStatusCodeSame(422);
|
||||||
self::assertArrayHasKey('firstName', $this->violationsByPath($response->toArray(false)));
|
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
|
public function testPostContactOnMissingProviderReturns404(): void
|
||||||
{
|
{
|
||||||
$client = $this->createAdminClient();
|
$client = $this->createAdminClient();
|
||||||
|
|||||||
Reference in New Issue
Block a user