Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a83adc00a | |||
| c76c447aa2 | |||
| 19ac8833eb | |||
| c25c33116d | |||
| 17aa61d014 | |||
| 3d4ae391fe |
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.117'
|
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."
|
||||||
},
|
},
|
||||||
@@ -418,18 +442,51 @@
|
|||||||
"remove": "Supprimer le contact",
|
"remove": "Supprimer le contact",
|
||||||
"add": "Nouveau contact"
|
"add": "Nouveau contact"
|
||||||
},
|
},
|
||||||
|
"address": {
|
||||||
|
"sites": "Sites",
|
||||||
|
"categories": "Catégorie",
|
||||||
|
"contacts": "Contact(s) rattaché(s)",
|
||||||
|
"country": "Pays",
|
||||||
|
"postalCode": "Code postal",
|
||||||
|
"city": "Ville",
|
||||||
|
"street": "Adresse",
|
||||||
|
"streetNotFound": "Adresse introuvable ? Saisissez-la directement.",
|
||||||
|
"streetComplement": "Adresse complémentaire",
|
||||||
|
"remove": "Supprimer l'adresse",
|
||||||
|
"add": "Nouvelle adresse",
|
||||||
|
"degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre."
|
||||||
|
},
|
||||||
|
"accounting": {
|
||||||
|
"siren": "SIREN",
|
||||||
|
"accountNumber": "Numéro de compte",
|
||||||
|
"tvaMode": "Mode de TVA",
|
||||||
|
"nTva": "N° de TVA",
|
||||||
|
"paymentDelay": "Délai de règlement",
|
||||||
|
"paymentType": "Type de règlement",
|
||||||
|
"bank": "Banque",
|
||||||
|
"ribLabel": "Libellé",
|
||||||
|
"ribBic": "BIC",
|
||||||
|
"ribIban": "IBAN",
|
||||||
|
"addRib": "Ajouter un RIB",
|
||||||
|
"removeRib": "Supprimer le RIB"
|
||||||
|
},
|
||||||
"confirmDelete": {
|
"confirmDelete": {
|
||||||
"title": "Confirmer la suppression",
|
"title": "Confirmer la suppression",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
"confirm": "Supprimer",
|
"confirm": "Supprimer",
|
||||||
"contact": "Supprimer ce contact ?"
|
"contact": "Supprimer ce contact ?",
|
||||||
|
"address": "Supprimer cette adresse ?",
|
||||||
|
"rib": "Supprimer ce RIB ?"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||||
|
<!-- Suppression : modal de confirmation cote parent. -->
|
||||||
|
<MalioButtonIcon
|
||||||
|
v-if="removable && !readonly"
|
||||||
|
icon="mdi:delete-outline"
|
||||||
|
variant="ghost"
|
||||||
|
button-class="absolute top-3 right-3"
|
||||||
|
v-bind="{ ariaLabel: t('technique.providers.form.address.remove') }"
|
||||||
|
@click="$emit('remove')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-3.05). -->
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="model.siteIris"
|
||||||
|
:options="siteOptions"
|
||||||
|
:label="t('technique.providers.form.address.sites')"
|
||||||
|
:display-tag="true"
|
||||||
|
:readonly="readonly"
|
||||||
|
:required="true"
|
||||||
|
:error="errors?.sites"
|
||||||
|
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Categories de type PRESTATAIRE (>= 1 obligatoire, RG-3.09). -->
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="model.categoryIris"
|
||||||
|
:options="categoryOptions"
|
||||||
|
:label="t('technique.providers.form.address.categories')"
|
||||||
|
:display-tag="true"
|
||||||
|
:readonly="readonly"
|
||||||
|
:required="true"
|
||||||
|
:error="errors?.categories"
|
||||||
|
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Contacts rattaches (M2M, facultatif) : alimente par l'onglet Contact. -->
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="model.contactIris"
|
||||||
|
:options="contactOptions"
|
||||||
|
:label="t('technique.providers.form.address.contacts')"
|
||||||
|
:display-tag="true"
|
||||||
|
:readonly="readonly"
|
||||||
|
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="model.country"
|
||||||
|
:options="countryOptions"
|
||||||
|
:label="t('technique.providers.form.address.country')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:required="true"
|
||||||
|
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="model.postalCode"
|
||||||
|
:label="t('technique.providers.form.address.postalCode')"
|
||||||
|
:mask="POSTAL_CODE_MASK"
|
||||||
|
:readonly="readonly"
|
||||||
|
:required="true"
|
||||||
|
:error="errors?.postalCode"
|
||||||
|
@update:model-value="onPostalCodeChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
|
||||||
|
<MalioSelect
|
||||||
|
v-if="!degraded"
|
||||||
|
:model-value="model.city"
|
||||||
|
:options="cityOptions"
|
||||||
|
:label="t('technique.providers.form.address.city')"
|
||||||
|
:readonly="readonly"
|
||||||
|
empty-option-label=""
|
||||||
|
:required="true"
|
||||||
|
:error="errors?.city"
|
||||||
|
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-else
|
||||||
|
:model-value="model.city"
|
||||||
|
:label="t('technique.providers.form.address.city')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:required="true"
|
||||||
|
:error="errors?.city"
|
||||||
|
@update:model-value="(v: string) => update('city', v)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
|
||||||
|
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
|
||||||
|
<div class="col-span-2">
|
||||||
|
<MalioInputAutocomplete
|
||||||
|
v-if="!readonly"
|
||||||
|
:model-value="model.street"
|
||||||
|
:options="addressOptions"
|
||||||
|
:loading="addressLoading"
|
||||||
|
:min-search-length="3"
|
||||||
|
:label="t('technique.providers.form.address.street')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:required="true"
|
||||||
|
:error="errors?.street"
|
||||||
|
:allow-create="true"
|
||||||
|
:no-results-text="t('technique.providers.form.address.streetNotFound')"
|
||||||
|
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
|
||||||
|
@search="onAddressSearch"
|
||||||
|
@select="onAddressSelect"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-else
|
||||||
|
:model-value="model.street"
|
||||||
|
:label="t('technique.providers.form.address.street')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:required="true"
|
||||||
|
:error="errors?.street"
|
||||||
|
@update:model-value="(v: string) => update('street', v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-1">
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="model.streetComplement"
|
||||||
|
:label="t('technique.providers.form.address.streetComplement')"
|
||||||
|
:readonly="readonly"
|
||||||
|
:error="errors?.streetComplement"
|
||||||
|
@update:model-value="(v: string) => update('streetComplement', v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
|
||||||
|
import type { RefOption } from '~/modules/technique/composables/useProviderReferentials'
|
||||||
|
import type { ProviderAddressFormDraft } from '~/modules/technique/types/providerForm'
|
||||||
|
|
||||||
|
// Masque code postal FR : 5 chiffres.
|
||||||
|
const POSTAL_CODE_MASK = '#####'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** Brouillon de l'adresse (v-model). */
|
||||||
|
modelValue: ProviderAddressFormDraft
|
||||||
|
/** Categories autorisees sur une adresse (type PRESTATAIRE). */
|
||||||
|
categoryOptions: RefOption[]
|
||||||
|
/** Sites Starseed disponibles. */
|
||||||
|
siteOptions: RefOption[]
|
||||||
|
/** Contacts deja saisis, rattachables a l'adresse. */
|
||||||
|
contactOptions: RefOption[]
|
||||||
|
/** Pays disponibles (France par defaut). */
|
||||||
|
countryOptions: RefOption[]
|
||||||
|
removable?: boolean
|
||||||
|
readonly?: boolean
|
||||||
|
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||||
|
errors?: Record<string, string>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: ProviderAddressFormDraft]
|
||||||
|
'remove': []
|
||||||
|
/** Emis une fois quand le service d'autocompletion bascule en indisponible. */
|
||||||
|
'degraded': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const autocomplete = useAddressAutocomplete()
|
||||||
|
|
||||||
|
const model = computed(() => props.modelValue)
|
||||||
|
|
||||||
|
// Repli saisie libre de la VILLE quand la BAN est indisponible (recuperable).
|
||||||
|
const degraded = ref(false)
|
||||||
|
let unavailableNotified = false
|
||||||
|
const banCityOptions = ref<RefOption[]>([])
|
||||||
|
const banAddressOptions = ref<RefOption[]>([])
|
||||||
|
|
||||||
|
// Options ville effectives : on garantit que la ville courante figure toujours
|
||||||
|
// dans la liste, sinon MalioSelect afficherait un champ vide en lecture seule.
|
||||||
|
const cityOptions = computed<RefOption[]>(() => {
|
||||||
|
const current = props.modelValue.city
|
||||||
|
if (current && !banCityOptions.value.some(o => o.value === current)) {
|
||||||
|
return [{ value: current, label: current }, ...banCityOptions.value]
|
||||||
|
}
|
||||||
|
return banCityOptions.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// Meme garantie pour le champ Adresse : la rue courante doit toujours figurer
|
||||||
|
// dans les options, sinon MalioInputAutocomplete laisse le champ vide.
|
||||||
|
const addressOptions = computed<RefOption[]>(() => {
|
||||||
|
const current = props.modelValue.street
|
||||||
|
if (current && !banAddressOptions.value.some(o => o.value === current)) {
|
||||||
|
return [{ value: current, label: current }, ...banAddressOptions.value]
|
||||||
|
}
|
||||||
|
return banAddressOptions.value
|
||||||
|
})
|
||||||
|
const addressLoading = ref(false)
|
||||||
|
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
|
||||||
|
let lastAddressSuggestions: AddressSuggestion[] = []
|
||||||
|
|
||||||
|
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
|
||||||
|
function update<K extends keyof ProviderAddressFormDraft>(field: K, value: ProviderAddressFormDraft[K]): void {
|
||||||
|
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
|
||||||
|
function notifyUnavailable(): void {
|
||||||
|
if (!unavailableNotified) {
|
||||||
|
unavailableNotified = true
|
||||||
|
emit('degraded')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville (RG-3.06). */
|
||||||
|
async function onPostalCodeChange(value: string): Promise<void> {
|
||||||
|
update('postalCode', value)
|
||||||
|
|
||||||
|
const digits = (value ?? '').replace(/\D/g, '')
|
||||||
|
if (digits.length < 5) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const suggestions = await autocomplete.searchCity(digits)
|
||||||
|
banCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
|
||||||
|
degraded.value = false
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
degraded.value = true
|
||||||
|
notifyUnavailable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recherche d'adresse assistee (event de MalioInputAutocomplete). */
|
||||||
|
async function onAddressSearch(query: string): Promise<void> {
|
||||||
|
// La BAN exige au moins 3 caracteres : on n'envoie rien en deca (evite un 400).
|
||||||
|
if (query.trim().length < 3) {
|
||||||
|
banAddressOptions.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addressLoading.value = true
|
||||||
|
try {
|
||||||
|
const postalCode = (model.value.postalCode ?? '').replace(/\D/g, '') || undefined
|
||||||
|
const suggestions = await autocomplete.searchAddress(query, postalCode)
|
||||||
|
lastAddressSuggestions = suggestions
|
||||||
|
banAddressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// Erreur transitoire : on vide les suggestions, la prochaine frappe reessaie.
|
||||||
|
banAddressOptions.value = []
|
||||||
|
notifyUnavailable()
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
addressLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Selection d'une suggestion d'adresse → remplit rue + ville + CP. */
|
||||||
|
function onAddressSelect(option: { label: string, value: string | number } | null): void {
|
||||||
|
if (option === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const suggestion = lastAddressSuggestions.find(s => s.street === option.value)
|
||||||
|
if (!suggestion) {
|
||||||
|
update('street', String(option.value))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('update:modelValue', {
|
||||||
|
...props.modelValue,
|
||||||
|
street: suggestion.street,
|
||||||
|
city: suggestion.city,
|
||||||
|
postalCode: suggestion.postalCode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { defineComponent, h, ref, computed } from 'vue'
|
||||||
|
import { emptyProviderAddress } from '~/modules/technique/types/providerForm'
|
||||||
|
import ProviderAddressBlock from '../ProviderAddressBlock.vue'
|
||||||
|
|
||||||
|
// Mocks controlables du composable BAN (hoisted), reutilise tel quel du M1/M2.
|
||||||
|
const { searchCityMock, searchAddressMock } = vi.hoisted(() => ({
|
||||||
|
searchCityMock: vi.fn(),
|
||||||
|
searchAddressMock: vi.fn(),
|
||||||
|
}))
|
||||||
|
vi.mock('~/shared/composables/useAddressAutocomplete', () => ({
|
||||||
|
useAddressAutocomplete: () => ({
|
||||||
|
searchCity: searchCityMock,
|
||||||
|
searchAddress: searchAddressMock,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Auto-imports Nuxt/Vue utilises sans import explicite par le composant.
|
||||||
|
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||||
|
vi.stubGlobal('ref', ref)
|
||||||
|
vi.stubGlobal('computed', computed)
|
||||||
|
|
||||||
|
// Stub de MalioInputAutocomplete : expose les `value` des options + allowCreate.
|
||||||
|
const MalioInputAutocompleteStub = defineComponent({
|
||||||
|
name: 'MalioInputAutocomplete',
|
||||||
|
props: {
|
||||||
|
modelValue: { type: [String, Number, null], default: undefined },
|
||||||
|
options: { type: Array as () => { value: string | number, label: string }[], default: () => [] },
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
minSearchLength: { type: Number, default: 0 },
|
||||||
|
label: { type: String, default: '' },
|
||||||
|
readonly: { type: Boolean, default: false },
|
||||||
|
allowCreate: { type: Boolean, default: false },
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue', 'search', 'select'],
|
||||||
|
setup(props) {
|
||||||
|
return () => h('div', {
|
||||||
|
'data-testid': 'addr-autocomplete',
|
||||||
|
'data-options': JSON.stringify(props.options.map(o => o.value)),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function mountBlock(overrides: Record<string, unknown> = {}, errors?: Record<string, string>) {
|
||||||
|
return mount(ProviderAddressBlock, {
|
||||||
|
props: {
|
||||||
|
modelValue: { ...emptyProviderAddress(), ...overrides },
|
||||||
|
categoryOptions: [],
|
||||||
|
siteOptions: [],
|
||||||
|
contactOptions: [],
|
||||||
|
countryOptions: [],
|
||||||
|
...(errors ? { errors } : {}),
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
MalioButtonIcon: true,
|
||||||
|
MalioSelect: true,
|
||||||
|
MalioSelectCheckbox: true,
|
||||||
|
MalioInputText: true,
|
||||||
|
MalioInputAutocomplete: MalioInputAutocompleteStub,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ProviderAddressBlock — version simplifiee M3 (pas de type/bennes/triage)', () => {
|
||||||
|
it('ne rend NI type d\'adresse, NI bennes, NI prestation de triage (difference M2)', () => {
|
||||||
|
const wrapper = mountBlock()
|
||||||
|
// Pas de stepper (bennes) ni de case a cocher (triage) dans le bloc M3.
|
||||||
|
expect(wrapper.find('malio-input-number-stub').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('malio-checkbox-stub').exists()).toBe(false)
|
||||||
|
// Aucun select ne porte le label « type d'adresse ».
|
||||||
|
const hasAddressType = wrapper.findAll('malio-select-stub').some(
|
||||||
|
el => el.attributes('label') === 'technique.providers.form.address.addressType',
|
||||||
|
)
|
||||||
|
expect(hasAddressType).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ProviderAddressBlock — mapping erreur par champ (ERP-101)', () => {
|
||||||
|
it('affiche les erreurs serveur sur sites et categories (RG-3.05 / RG-3.09)', () => {
|
||||||
|
const wrapper = mountBlock({}, {
|
||||||
|
sites: 'Au moins un site est obligatoire.',
|
||||||
|
categories: 'Au moins une catégorie est obligatoire.',
|
||||||
|
})
|
||||||
|
const checkboxes = wrapper.findAll('malio-select-checkbox-stub')
|
||||||
|
const sitesField = checkboxes.find(el => el.attributes('label') === 'technique.providers.form.address.sites')
|
||||||
|
const categoriesField = checkboxes.find(el => el.attributes('label') === 'technique.providers.form.address.categories')
|
||||||
|
|
||||||
|
expect(sitesField?.attributes('error')).toBe('Au moins un site est obligatoire.')
|
||||||
|
expect(categoriesField?.attributes('error')).toBe('Au moins une catégorie est obligatoire.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('affiche l\'erreur serveur sur le code postal', () => {
|
||||||
|
const wrapper = mountBlock({}, { postalCode: 'Code postal invalide.' })
|
||||||
|
const field = wrapper.findAll('malio-input-text-stub').find(
|
||||||
|
el => el.attributes('label') === 'technique.providers.form.address.postalCode',
|
||||||
|
)
|
||||||
|
expect(field?.attributes('error')).toBe('Code postal invalide.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ProviderAddressBlock — autocompletion BAN (RG-3.06)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
searchCityMock.mockReset()
|
||||||
|
searchAddressMock.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('n\'appelle pas la BAN en deca de 3 caracteres', async () => {
|
||||||
|
const wrapper = mountBlock()
|
||||||
|
wrapper.findComponent(MalioInputAutocompleteStub).vm.$emit('search', 'ab')
|
||||||
|
await flushPromises()
|
||||||
|
expect(searchAddressMock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('relance la recherche apres une erreur (pas de bascule definitive)', async () => {
|
||||||
|
searchAddressMock
|
||||||
|
.mockRejectedValueOnce(new Error('BAN indisponible'))
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{ label: '1 rue du Test, Châtellerault', street: '1 rue du Test', postalCode: '86100', city: 'Châtellerault' },
|
||||||
|
])
|
||||||
|
const wrapper = mountBlock()
|
||||||
|
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
|
||||||
|
|
||||||
|
auto.vm.$emit('search', 'rue du test')
|
||||||
|
await flushPromises()
|
||||||
|
auto.vm.$emit('search', 'rue du teste')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(searchAddressMock).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cas degrade : la BAN echoue -> emet « degraded » une seule fois (RG-3.06)', async () => {
|
||||||
|
searchAddressMock.mockRejectedValue(new Error('BAN indisponible'))
|
||||||
|
const wrapper = mountBlock()
|
||||||
|
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
|
||||||
|
|
||||||
|
auto.vm.$emit('search', 'rue du test')
|
||||||
|
await flushPromises()
|
||||||
|
auto.vm.$emit('search', 'rue du teste')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.emitted('degraded')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('active allow-create sur le champ Adresse (saisie manuelle libre)', () => {
|
||||||
|
const wrapper = mountBlock()
|
||||||
|
expect(wrapper.findComponent(MalioInputAutocompleteStub).props('allowCreate')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('inclut la rue courante dans les options meme sans recherche BAN', () => {
|
||||||
|
const wrapper = mountBlock({ street: '1 rue du Test' })
|
||||||
|
const values = JSON.parse(wrapper.find('[data-testid="addr-autocomplete"]').attributes('data-options') ?? '[]')
|
||||||
|
expect(values).toContain('1 rue du Test')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -19,8 +19,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
|
|
||||||
const mockPost = vi.hoisted(() => vi.fn())
|
const mockPost = vi.hoisted(() => vi.fn())
|
||||||
const mockPatch = vi.hoisted(() => vi.fn())
|
const mockPatch = vi.hoisted(() => vi.fn())
|
||||||
// Permission accounting.view pilotable par test (presence de l'onglet Comptabilite).
|
// Permissions comptables pilotables par test (presence/edition de l'onglet Comptabilite).
|
||||||
const permState = vi.hoisted(() => ({ accountingView: false }))
|
const permState = vi.hoisted(() => ({ accountingView: false, accountingManage: false }))
|
||||||
|
|
||||||
vi.stubGlobal('useApi', () => ({
|
vi.stubGlobal('useApi', () => ({
|
||||||
get: vi.fn(),
|
get: vi.fn(),
|
||||||
@@ -37,11 +37,15 @@ vi.stubGlobal('useToast', () => ({
|
|||||||
info: vi.fn(),
|
info: vi.fn(),
|
||||||
}))
|
}))
|
||||||
vi.stubGlobal('usePermissions', () => ({
|
vi.stubGlobal('usePermissions', () => ({
|
||||||
can: (perm: string) => perm === 'technique.providers.accounting.view' ? permState.accountingView : true,
|
can: (perm: string) => {
|
||||||
|
if (perm === 'technique.providers.accounting.view') return permState.accountingView
|
||||||
|
if (perm === 'technique.providers.accounting.manage') return permState.accountingManage
|
||||||
|
return true
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const { useProviderForm, buildProviderCreateTabKeys } = await import('../useProviderForm')
|
const { useProviderForm, buildProviderCreateTabKeys } = await import('../useProviderForm')
|
||||||
const { emptyProviderContact } = await import('~/modules/technique/types/providerForm')
|
const { emptyProviderContact, emptyProviderAddress } = await import('~/modules/technique/types/providerForm')
|
||||||
type ProviderForm = ReturnType<typeof useProviderForm>
|
type ProviderForm = ReturnType<typeof useProviderForm>
|
||||||
|
|
||||||
const SITE_86 = '/api/sites/1'
|
const SITE_86 = '/api/sites/1'
|
||||||
@@ -52,21 +56,27 @@ function contactAt(form: ProviderForm, index = 0) {
|
|||||||
return form.contacts.value[index] ?? emptyProviderContact()
|
return form.contacts.value[index] ?? emptyProviderContact()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Accede a un bloc adresse (idem). */
|
||||||
|
function addressAt(form: ProviderForm, index = 0) {
|
||||||
|
return form.addresses.value[index] ?? emptyProviderAddress()
|
||||||
|
}
|
||||||
|
|
||||||
describe('useProviderForm', () => {
|
describe('useProviderForm', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockPost.mockReset()
|
mockPost.mockReset()
|
||||||
mockPatch.mockReset()
|
mockPatch.mockReset()
|
||||||
permState.accountingView = false
|
permState.accountingView = 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)
|
||||||
@@ -112,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 () => {
|
||||||
@@ -203,6 +212,7 @@ describe('useProviderForm — onglet Contact (ERP-142)', () => {
|
|||||||
mockPost.mockReset()
|
mockPost.mockReset()
|
||||||
mockPatch.mockReset()
|
mockPatch.mockReset()
|
||||||
permState.accountingView = false
|
permState.accountingView = false
|
||||||
|
permState.accountingManage = false
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Place le formulaire en etat « prestataire cree » (onglet Contact accessible). */
|
/** Place le formulaire en etat « prestataire cree » (onglet Contact accessible). */
|
||||||
@@ -304,3 +314,340 @@ describe('useProviderForm — onglet Contact (ERP-142)', () => {
|
|||||||
expect(form.contactErrors.value[1]?.email).toBe('L\'adresse email n\'est pas valide.')
|
expect(form.contactErrors.value[1]?.email).toBe('L\'adresse email n\'est pas valide.')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('useProviderForm — onglet Adresse (ERP-143)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPost.mockReset()
|
||||||
|
mockPatch.mockReset()
|
||||||
|
permState.accountingView = false
|
||||||
|
permState.accountingManage = false
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Place le formulaire en etat « prestataire cree » (onglet Adresse accessible). */
|
||||||
|
function createdForm() {
|
||||||
|
const form = useProviderForm()
|
||||||
|
form.providerId.value = 7
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remplit un bloc adresse valide (site + categorie + scalaires requis). */
|
||||||
|
function fillValidAddress(form: ProviderForm, index = 0): void {
|
||||||
|
const a = addressAt(form, index)
|
||||||
|
a.siteIris = [SITE_86]
|
||||||
|
a.categoryIris = [CAT_MAINT]
|
||||||
|
a.postalCode = '86100'
|
||||||
|
a.city = 'Châtellerault'
|
||||||
|
a.street = '1 rue du Test'
|
||||||
|
}
|
||||||
|
|
||||||
|
it('RG-3.05 : « + Nouvelle adresse » desactive tant que site + categorie manquent', () => {
|
||||||
|
const form = createdForm()
|
||||||
|
expect(form.canAddAddress.value).toBe(false)
|
||||||
|
|
||||||
|
// no-op tant que l'adresse n'est pas valide.
|
||||||
|
form.addAddress()
|
||||||
|
expect(form.addresses.value).toHaveLength(1)
|
||||||
|
|
||||||
|
addressAt(form).siteIris = [SITE_86]
|
||||||
|
expect(form.canAddAddress.value).toBe(false) // categorie manquante
|
||||||
|
addressAt(form).categoryIris = [CAT_MAINT]
|
||||||
|
expect(form.canAddAddress.value).toBe(true)
|
||||||
|
form.addAddress()
|
||||||
|
expect(form.addresses.value).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removeAddress retire le bloc et son erreur de ligne', () => {
|
||||||
|
const form = createdForm()
|
||||||
|
fillValidAddress(form)
|
||||||
|
form.addAddress()
|
||||||
|
form.addressErrors.value = [{}, { city: 'x' }]
|
||||||
|
|
||||||
|
form.removeAddress(1)
|
||||||
|
expect(form.addresses.value).toHaveLength(1)
|
||||||
|
expect(form.addressErrors.value).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submitAddresses : POST des nouvelles, capture l\'id, finalise l\'onglet', async () => {
|
||||||
|
mockPost.mockResolvedValueOnce({ id: 88 })
|
||||||
|
const form = createdForm()
|
||||||
|
fillValidAddress(form)
|
||||||
|
|
||||||
|
const ok = await form.submitAddresses(vi.fn())
|
||||||
|
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
const [url, body, opts] = mockPost.mock.calls[0] ?? []
|
||||||
|
expect(url).toBe('/providers/7/addresses')
|
||||||
|
expect(body).toMatchObject({ sites: [SITE_86], categories: [CAT_MAINT], city: 'Châtellerault' })
|
||||||
|
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
|
||||||
|
expect(addressAt(form).id).toBe(88)
|
||||||
|
expect(form.isValidated('address')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submitAddresses : PATCH des adresses existantes sur /provider_addresses/{id}', async () => {
|
||||||
|
mockPatch.mockResolvedValueOnce({})
|
||||||
|
const form = createdForm()
|
||||||
|
fillValidAddress(form)
|
||||||
|
addressAt(form).id = 88
|
||||||
|
|
||||||
|
await form.submitAddresses(vi.fn())
|
||||||
|
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith('/provider_addresses/88', expect.objectContaining({ sites: [SITE_86] }), { toast: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mappe les erreurs 422 PAR LIGNE et ne finalise pas l\'onglet', async () => {
|
||||||
|
mockPost.mockRejectedValueOnce({
|
||||||
|
response: {
|
||||||
|
status: 422,
|
||||||
|
_data: { violations: [{ propertyPath: 'city', message: 'La ville est obligatoire.' }] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const form = createdForm()
|
||||||
|
fillValidAddress(form)
|
||||||
|
|
||||||
|
const ok = await form.submitAddresses(vi.fn())
|
||||||
|
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(form.addressErrors.value[0]?.city).toBe('La ville est obligatoire.')
|
||||||
|
expect(form.isValidated('address')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useProviderForm — onglet Comptabilite (ERP-144)', () => {
|
||||||
|
const TVA = '/api/tva_modes/1'
|
||||||
|
const DELAY = '/api/payment_delays/1'
|
||||||
|
const TYPE = '/api/payment_types/3'
|
||||||
|
const BANK = '/api/banks/2'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockPost.mockReset()
|
||||||
|
mockPatch.mockReset()
|
||||||
|
permState.accountingView = true
|
||||||
|
permState.accountingManage = true
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Prestataire cree, onglet Comptabilite editable (view + manage). */
|
||||||
|
function createdForm() {
|
||||||
|
const form = useProviderForm()
|
||||||
|
form.providerId.value = 7
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remplit les scalaires comptables communs. */
|
||||||
|
function fillScalars(form: ProviderForm): void {
|
||||||
|
form.accounting.siren = '123456789'
|
||||||
|
form.accounting.accountNumber = '4010'
|
||||||
|
form.accounting.tvaModeIri = TVA
|
||||||
|
form.accounting.nTva = 'FR123'
|
||||||
|
form.accounting.paymentDelayIri = DELAY
|
||||||
|
form.accounting.paymentTypeIri = TYPE
|
||||||
|
}
|
||||||
|
|
||||||
|
it('lecture seule sans accounting.manage (Compta consultation / autres roles)', () => {
|
||||||
|
permState.accountingManage = false
|
||||||
|
const form = createdForm()
|
||||||
|
expect(form.accountingReadonly.value).toBe(true)
|
||||||
|
|
||||||
|
permState.accountingManage = true
|
||||||
|
const form2 = createdForm()
|
||||||
|
expect(form2.accountingReadonly.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('RG-3.07 : setPaymentType(VIREMENT) garde la banque ; un autre type la vide', () => {
|
||||||
|
const form = createdForm()
|
||||||
|
form.accounting.bankIri = BANK
|
||||||
|
|
||||||
|
// Type VIREMENT -> banque requise, conservee.
|
||||||
|
form.setPaymentType(TYPE, true, false)
|
||||||
|
expect(form.accounting.bankIri).toBe(BANK)
|
||||||
|
|
||||||
|
// Type non-VIREMENT -> banque videe (sans objet).
|
||||||
|
form.setPaymentType(TYPE, false, false)
|
||||||
|
expect(form.accounting.bankIri).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('RG-3.08 : setPaymentType(LCR) garantit au moins un bloc RIB', () => {
|
||||||
|
const form = createdForm()
|
||||||
|
expect(form.ribs.value).toHaveLength(0)
|
||||||
|
|
||||||
|
form.setPaymentType(TYPE, false, true)
|
||||||
|
expect(form.ribs.value).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('« + RIB » desactive tant que le dernier RIB est incomplet (RG-3.08)', () => {
|
||||||
|
const form = createdForm()
|
||||||
|
form.setPaymentType(TYPE, false, true)
|
||||||
|
expect(form.canAddRib.value).toBe(false)
|
||||||
|
|
||||||
|
const rib = form.ribs.value[0]
|
||||||
|
if (rib) {
|
||||||
|
rib.label = 'Compte'
|
||||||
|
rib.bic = 'BNPAFRPP'
|
||||||
|
rib.iban = 'FR76...'
|
||||||
|
}
|
||||||
|
expect(form.canAddRib.value).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('VIREMENT : PATCH des scalaires avec banque, aucun appel RIB', async () => {
|
||||||
|
mockPatch.mockResolvedValueOnce({})
|
||||||
|
const form = createdForm()
|
||||||
|
fillScalars(form)
|
||||||
|
form.accounting.bankIri = BANK
|
||||||
|
|
||||||
|
const ok = await form.submitAccounting(true, false, vi.fn())
|
||||||
|
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith(
|
||||||
|
'/providers/7',
|
||||||
|
expect.objectContaining({ paymentType: TYPE, bank: BANK, siren: '123456789' }),
|
||||||
|
{ toast: false },
|
||||||
|
)
|
||||||
|
expect(form.isValidated('accounting')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hors VIREMENT : la banque part a null dans le payload (RG-3.07)', async () => {
|
||||||
|
mockPatch.mockResolvedValueOnce({})
|
||||||
|
const form = createdForm()
|
||||||
|
fillScalars(form)
|
||||||
|
form.accounting.bankIri = BANK // residu : doit etre ignore (isBankRequired=false)
|
||||||
|
|
||||||
|
await form.submitAccounting(false, false, vi.fn())
|
||||||
|
|
||||||
|
const body = mockPatch.mock.calls[0]?.[1] as Record<string, unknown>
|
||||||
|
expect(body.bank).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('LCR : POST des RIB AVANT le PATCH des scalaires (ordre RG-3.08)', async () => {
|
||||||
|
mockPost.mockResolvedValueOnce({ id: 50 })
|
||||||
|
mockPatch.mockResolvedValueOnce({})
|
||||||
|
const form = createdForm()
|
||||||
|
fillScalars(form)
|
||||||
|
form.setPaymentType(TYPE, false, true)
|
||||||
|
const rib = form.ribs.value[0]
|
||||||
|
if (rib) {
|
||||||
|
rib.label = 'Compte'
|
||||||
|
rib.bic = 'BNPAFRPP'
|
||||||
|
rib.iban = 'FR76...'
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await form.submitAccounting(false, true, vi.fn())
|
||||||
|
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'/providers/7/ribs',
|
||||||
|
expect.objectContaining({ label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' }),
|
||||||
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
|
)
|
||||||
|
expect(form.ribs.value[0]?.id).toBe(50)
|
||||||
|
// Le PATCH des scalaires intervient APRES la creation du RIB.
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith('/providers/7', expect.any(Object), { toast: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('422 sur les scalaires (bank) : mapping inline, onglet non finalise', async () => {
|
||||||
|
mockPatch.mockRejectedValueOnce({
|
||||||
|
response: {
|
||||||
|
status: 422,
|
||||||
|
_data: { violations: [{ propertyPath: 'bank', message: 'La banque est obligatoire pour un virement.' }] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const form = createdForm()
|
||||||
|
fillScalars(form)
|
||||||
|
|
||||||
|
const ok = await form.submitAccounting(true, false, vi.fn())
|
||||||
|
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(form.accountingErrors.errors.bank).toBe('La banque est obligatoire pour un virement.')
|
||||||
|
expect(form.isValidated('accounting')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('LCR : 422 RIB par ligne -> pas de PATCH des scalaires', async () => {
|
||||||
|
mockPost.mockRejectedValueOnce({
|
||||||
|
response: {
|
||||||
|
status: 422,
|
||||||
|
_data: { violations: [{ propertyPath: 'iban', message: 'L\'IBAN est obligatoire.' }] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const form = createdForm()
|
||||||
|
fillScalars(form)
|
||||||
|
form.setPaymentType(TYPE, false, true)
|
||||||
|
const rib = form.ribs.value[0]
|
||||||
|
if (rib) {
|
||||||
|
rib.label = 'Compte'
|
||||||
|
rib.bic = 'BNPAFRPP'
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await form.submitAccounting(false, true, vi.fn())
|
||||||
|
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(form.ribErrors.value[0]?.iban).toBe('L\'IBAN est obligatoire.')
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,17 +2,36 @@ import { computed, reactive, ref, type Ref } from 'vue'
|
|||||||
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
||||||
import { mapViolationsToRecord } from '~/shared/utils/api'
|
import { mapViolationsToRecord } from '~/shared/utils/api'
|
||||||
import {
|
import {
|
||||||
|
emptyProviderAccounting,
|
||||||
|
emptyProviderAddress,
|
||||||
emptyProviderContact,
|
emptyProviderContact,
|
||||||
emptyProviderMain,
|
emptyProviderMain,
|
||||||
|
emptyProviderRib,
|
||||||
|
type ProviderAccountingDraft,
|
||||||
|
type ProviderAddressFormDraft,
|
||||||
|
type ProviderAddressResponse,
|
||||||
type ProviderContactFormDraft,
|
type ProviderContactFormDraft,
|
||||||
type ProviderContactResponse,
|
type ProviderContactResponse,
|
||||||
type ProviderMainDraft,
|
type ProviderMainDraft,
|
||||||
type ProviderMainResponse,
|
type ProviderMainResponse,
|
||||||
|
type ProviderRibFormDraft,
|
||||||
|
type ProviderRibResponse,
|
||||||
} from '~/modules/technique/types/providerForm'
|
} from '~/modules/technique/types/providerForm'
|
||||||
import {
|
import {
|
||||||
buildProviderContactPayload,
|
buildProviderContactPayload,
|
||||||
isProviderContactBlank,
|
isProviderContactBlank,
|
||||||
|
isProviderContactNamed,
|
||||||
} from '~/modules/technique/utils/forms/providerContact'
|
} from '~/modules/technique/utils/forms/providerContact'
|
||||||
|
import {
|
||||||
|
buildProviderAddressPayload,
|
||||||
|
isProviderAddressValid,
|
||||||
|
} from '~/modules/technique/utils/forms/providerAddress'
|
||||||
|
import {
|
||||||
|
buildProviderAccountingPayload,
|
||||||
|
buildProviderRibPayload,
|
||||||
|
isRibBlank,
|
||||||
|
isRibComplete,
|
||||||
|
} from '~/modules/technique/utils/forms/providerAccounting'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Workflow de l'ecran « Ajouter un prestataire » (M3 Technique, ERP-141) —
|
* Workflow de l'ecran « Ajouter un prestataire » (M3 Technique, ERP-141) —
|
||||||
@@ -65,6 +84,7 @@ export function useProviderForm() {
|
|||||||
|
|
||||||
// ── Onglets : ordre + gating progressif ───────────────────────────────────
|
// ── Onglets : ordre + gating progressif ───────────────────────────────────
|
||||||
const canAccountingView = computed(() => can('technique.providers.accounting.view'))
|
const canAccountingView = computed(() => can('technique.providers.accounting.view'))
|
||||||
|
const canAccountingManage = computed(() => can('technique.providers.accounting.manage'))
|
||||||
const tabKeys = computed(() => buildProviderCreateTabKeys(canAccountingView.value))
|
const tabKeys = computed(() => buildProviderCreateTabKeys(canAccountingView.value))
|
||||||
|
|
||||||
// Index du dernier onglet deverrouille (-1 tant que le prestataire n'est pas cree).
|
// Index du dernier onglet deverrouille (-1 tant que le prestataire n'est pas cree).
|
||||||
@@ -72,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
|
||||||
@@ -89,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
|
||||||
@@ -173,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]
|
||||||
@@ -234,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 {
|
||||||
@@ -298,6 +369,195 @@ export function useProviderForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Onglet Adresse (ERP-143) ──────────────────────────────────────────────
|
||||||
|
const addresses = ref<ProviderAddressFormDraft[]>([emptyProviderAddress()])
|
||||||
|
// Erreurs 422 par ligne (alignees sur l'index du v-for).
|
||||||
|
const addressErrors = ref<Record<string, string>[]>([])
|
||||||
|
|
||||||
|
// « + Nouvelle adresse » desactive tant que la derniere adresse n'a pas
|
||||||
|
// au moins un site ET une categorie (RG-3.05 / RG-3.09).
|
||||||
|
const canAddAddress = computed(() => {
|
||||||
|
const last = addresses.value[addresses.value.length - 1]
|
||||||
|
return last !== undefined && isProviderAddressValid(last)
|
||||||
|
})
|
||||||
|
|
||||||
|
function addAddress(): void {
|
||||||
|
if (canAddAddress.value) {
|
||||||
|
addresses.value.push(emptyProviderAddress())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAddress(index: number): void {
|
||||||
|
addresses.value.splice(index, 1)
|
||||||
|
addressErrors.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valide l'onglet Adresse : POST des nouvelles adresses sur
|
||||||
|
* /providers/{id}/addresses, PATCH des existantes sur /provider_addresses/{id}
|
||||||
|
* (sous-ressource, groupe provider:write:addresses). Erreurs 422 collectees par
|
||||||
|
* ligne. Retourne true si l'onglet a ete valide (avance/termine).
|
||||||
|
*/
|
||||||
|
async function submitAddresses(onError: (error: unknown) => void): Promise<boolean> {
|
||||||
|
if (providerId.value === null || tabSubmitting.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
tabSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const hasError = await submitRows(
|
||||||
|
addresses.value,
|
||||||
|
addressErrors,
|
||||||
|
async (address) => {
|
||||||
|
const body = buildProviderAddressPayload(address)
|
||||||
|
if (address.id === null) {
|
||||||
|
const created = await api.post<ProviderAddressResponse>(
|
||||||
|
`/providers/${providerId.value}/addresses`,
|
||||||
|
body,
|
||||||
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
|
)
|
||||||
|
address.id = created.id
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await api.patch(`/provider_addresses/${address.id}`, body, { toast: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
)
|
||||||
|
if (hasError) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
completeTab('address')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
tabSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Onglet Comptabilite (ERP-144) ─────────────────────────────────────────
|
||||||
|
const accounting = reactive<ProviderAccountingDraft>(emptyProviderAccounting())
|
||||||
|
const ribs = ref<ProviderRibFormDraft[]>([])
|
||||||
|
const accountingErrors = useFormErrors()
|
||||||
|
// Erreurs 422 par ligne de RIB (alignees sur l'index du v-for).
|
||||||
|
const ribErrors = ref<Record<string, string>[]>([])
|
||||||
|
|
||||||
|
// L'onglet est editable seulement avec accounting.manage (sinon lecture seule).
|
||||||
|
const accountingReadonly = computed(() => isValidated('accounting') || !canAccountingManage.value)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met a jour le type de reglement (IRI) en propageant ses RG inter-champs :
|
||||||
|
* - hors VIREMENT (RG-3.07) : on vide la banque (sans objet) ;
|
||||||
|
* - LCR (RG-3.08) : on garantit au moins un bloc RIB visible ; hors LCR, on
|
||||||
|
* purge les erreurs de RIB (les blocs sont conserves mais non persistes).
|
||||||
|
* `isBankRequired` / `isRibRequired` sont calcules par l'appelant (page) a
|
||||||
|
* partir du code resolu via les referentiels.
|
||||||
|
*/
|
||||||
|
function setPaymentType(iri: string | null, isBankRequired: boolean, isRibRequired: boolean): void {
|
||||||
|
accounting.paymentTypeIri = iri
|
||||||
|
if (!isBankRequired) {
|
||||||
|
accounting.bankIri = null
|
||||||
|
}
|
||||||
|
if (isRibRequired) {
|
||||||
|
if (ribs.value.length === 0) {
|
||||||
|
ribs.value.push(emptyProviderRib())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
ribErrors.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// « + RIB » desactive tant que le dernier bloc RIB n'est pas complet (RG-3.08).
|
||||||
|
const canAddRib = computed(() => {
|
||||||
|
const last = ribs.value[ribs.value.length - 1]
|
||||||
|
return last !== undefined && isRibComplete(last)
|
||||||
|
})
|
||||||
|
|
||||||
|
function addRib(): void {
|
||||||
|
if (canAddRib.value) {
|
||||||
|
ribs.value.push(emptyProviderRib())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRib(index: number): void {
|
||||||
|
ribs.value.splice(index, 1)
|
||||||
|
ribErrors.value.splice(index, 1)
|
||||||
|
// Garde au moins un bloc RIB visible (sous LCR).
|
||||||
|
if (ribs.value.length === 0) {
|
||||||
|
ribs.value.push(emptyProviderRib())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valide l'onglet Comptabilite : (1) sous LCR, POST/PATCH des RIB d'abord
|
||||||
|
* (le back valide RG-3.08 sur le PATCH scalaires, les RIB doivent donc exister
|
||||||
|
* AVANT) ; (2) PATCH des scalaires comptables (groupe provider:write:accounting,
|
||||||
|
* banque envoyee seulement si VIREMENT — RG-3.07). Erreurs RIB par ligne ;
|
||||||
|
* erreurs scalaires inline (bank/paymentType). Retourne true si l'onglet a ete
|
||||||
|
* valide.
|
||||||
|
*/
|
||||||
|
async function submitAccounting(
|
||||||
|
isBankRequired: boolean,
|
||||||
|
isRibRequired: boolean,
|
||||||
|
onRibError: (error: unknown) => void,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (providerId.value === null || tabSubmitting.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
tabSubmitting.value = true
|
||||||
|
accountingErrors.clearErrors()
|
||||||
|
try {
|
||||||
|
// 1) RIB d'abord, uniquement sous LCR. Une amorce vide neuve est sautee
|
||||||
|
// s'il reste un autre RIB soumettable ; sinon (LCR sans aucun RIB rempli)
|
||||||
|
// on la soumet pour declencher la 422 NotBlank inline.
|
||||||
|
if (isRibRequired) {
|
||||||
|
const hasSubmittableRib = ribs.value.some(r => r.id !== null || !isRibBlank(r))
|
||||||
|
const ribHasError = await submitRows(
|
||||||
|
ribs.value,
|
||||||
|
ribErrors,
|
||||||
|
async (rib) => {
|
||||||
|
const body = buildProviderRibPayload(rib)
|
||||||
|
if (rib.id === null) {
|
||||||
|
const created = await api.post<ProviderRibResponse>(
|
||||||
|
`/providers/${providerId.value}/ribs`,
|
||||||
|
body,
|
||||||
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
|
)
|
||||||
|
rib.id = created.id
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await api.patch(`/provider_ribs/${rib.id}`, body, { toast: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRibError,
|
||||||
|
rib => hasSubmittableRib && rib.id === null && isRibBlank(rib),
|
||||||
|
)
|
||||||
|
if (ribHasError) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) PATCH des scalaires comptables (erreurs inline sur leurs champs).
|
||||||
|
try {
|
||||||
|
await api.patch(
|
||||||
|
`/providers/${providerId.value}`,
|
||||||
|
buildProviderAccountingPayload(accounting, isBankRequired),
|
||||||
|
{ toast: false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
accountingErrors.handleApiError(error, { fallbackMessage: t('technique.providers.toast.error') })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
completeTab('accounting')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
tabSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// etat
|
// etat
|
||||||
main,
|
main,
|
||||||
@@ -308,10 +568,12 @@ export function useProviderForm() {
|
|||||||
mainErrors,
|
mainErrors,
|
||||||
// onglets
|
// onglets
|
||||||
canAccountingView,
|
canAccountingView,
|
||||||
|
canAccountingManage,
|
||||||
tabKeys,
|
tabKeys,
|
||||||
activeTab,
|
activeTab,
|
||||||
unlockedIndex,
|
unlockedIndex,
|
||||||
validated,
|
validated,
|
||||||
|
editMode,
|
||||||
isValidated,
|
isValidated,
|
||||||
// contacts
|
// contacts
|
||||||
contacts,
|
contacts,
|
||||||
@@ -320,10 +582,29 @@ export function useProviderForm() {
|
|||||||
addContact,
|
addContact,
|
||||||
removeContact,
|
removeContact,
|
||||||
submitContacts,
|
submitContacts,
|
||||||
|
// adresses
|
||||||
|
addresses,
|
||||||
|
addressErrors,
|
||||||
|
canAddAddress,
|
||||||
|
addAddress,
|
||||||
|
removeAddress,
|
||||||
|
submitAddresses,
|
||||||
|
// comptabilite
|
||||||
|
accounting,
|
||||||
|
ribs,
|
||||||
|
accountingErrors,
|
||||||
|
ribErrors,
|
||||||
|
accountingReadonly,
|
||||||
|
setPaymentType,
|
||||||
|
canAddRib,
|
||||||
|
addRib,
|
||||||
|
removeRib,
|
||||||
|
submitAccounting,
|
||||||
// actions
|
// actions
|
||||||
validateMainFront,
|
validateMainFront,
|
||||||
buildMainPayload,
|
buildMainPayload,
|
||||||
submitMain,
|
submitMain,
|
||||||
|
updateMain,
|
||||||
patchProvider,
|
patchProvider,
|
||||||
completeTab,
|
completeTab,
|
||||||
submitRows,
|
submitRows,
|
||||||
|
|||||||
@@ -28,10 +28,20 @@ export interface RefOption {
|
|||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Option de type de reglement enrichie de son code stable (RG-3.07 / RG-3.08). */
|
||||||
|
export interface PaymentTypeOption extends RefOption {
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
|
||||||
interface HydraMember {
|
interface HydraMember {
|
||||||
'@id': string
|
'@id': string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ReferentialMember extends HydraMember {
|
||||||
|
code: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
interface CategoryMember extends HydraMember {
|
interface CategoryMember extends HydraMember {
|
||||||
code: string
|
code: string
|
||||||
name: string
|
name: string
|
||||||
@@ -42,6 +52,11 @@ interface SiteMember extends HydraMember {
|
|||||||
postalCode: string
|
postalCode: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CountryMember extends HydraMember {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
|
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
|
||||||
|
|
||||||
export function useProviderReferentials() {
|
export function useProviderReferentials() {
|
||||||
@@ -49,6 +64,12 @@ export function useProviderReferentials() {
|
|||||||
|
|
||||||
const categories = ref<RefOption[]>([])
|
const categories = ref<RefOption[]>([])
|
||||||
const sites = ref<RefOption[]>([])
|
const sites = ref<RefOption[]>([])
|
||||||
|
const countries = ref<RefOption[]>([])
|
||||||
|
// Referentiels comptables (charges a la demande via loadAccounting).
|
||||||
|
const tvaModes = ref<RefOption[]>([])
|
||||||
|
const paymentDelays = ref<RefOption[]>([])
|
||||||
|
const paymentTypes = ref<PaymentTypeOption[]>([])
|
||||||
|
const banks = ref<RefOption[]>([])
|
||||||
|
|
||||||
/** Recupere une collection complete (pagination desactivee) en Hydra. */
|
/** Recupere une collection complete (pagination desactivee) en Hydra. */
|
||||||
async function fetchAll<T extends HydraMember>(
|
async function fetchAll<T extends HydraMember>(
|
||||||
@@ -74,12 +95,42 @@ export function useProviderReferentials() {
|
|||||||
// du code postal du site), ex: 86100 -> « 86 », 17400 -> « 17 ».
|
// du code postal du site), ex: 86100 -> « 86 », 17400 -> « 17 ».
|
||||||
fetchAll<SiteMember>('/sites')
|
fetchAll<SiteMember>('/sites')
|
||||||
.then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2) })) }),
|
.then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2) })) }),
|
||||||
|
// Pays (ERP-116) : la valeur d'option est le NOM du pays (l'adresse stocke
|
||||||
|
// `country` en chaine libre, « France »...). value === label. Aligne sur
|
||||||
|
// les ecrans client/fournisseur. Sert le select Pays de l'onglet Adresse.
|
||||||
|
fetchAll<CountryMember>('/countries')
|
||||||
|
.then((list) => { countries.value = list.map(c => ({ value: c.name, label: c.name })) }),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge les referentiels comptables (onglet Comptabilite, ERP-144). Appele
|
||||||
|
* uniquement quand l'utilisateur peut voir l'onglet (accounting.view). Resilient
|
||||||
|
* (allSettled) : un referentiel en echec reste vide.
|
||||||
|
*/
|
||||||
|
async function loadAccounting(): Promise<void> {
|
||||||
|
await Promise.allSettled([
|
||||||
|
fetchAll<ReferentialMember>('/tva_modes')
|
||||||
|
.then((list) => { tvaModes.value = list.map(t => ({ value: t['@id'], label: t.label })) }),
|
||||||
|
fetchAll<ReferentialMember>('/payment_delays')
|
||||||
|
.then((list) => { paymentDelays.value = list.map(d => ({ value: d['@id'], label: d.label })) }),
|
||||||
|
// Le code stable du type sert les RG-3.07 (VIREMENT) / RG-3.08 (LCR).
|
||||||
|
fetchAll<ReferentialMember>('/payment_types')
|
||||||
|
.then((list) => { paymentTypes.value = list.map(p => ({ value: p['@id'], label: p.label, code: p.code })) }),
|
||||||
|
fetchAll<ReferentialMember>('/banks')
|
||||||
|
.then((list) => { banks.value = list.map(b => ({ value: b['@id'], label: b.label })) }),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
categories,
|
categories,
|
||||||
sites,
|
sites,
|
||||||
|
countries,
|
||||||
|
tvaModes,
|
||||||
|
paymentDelays,
|
||||||
|
paymentTypes,
|
||||||
|
banks,
|
||||||
loadMain,
|
loadMain,
|
||||||
|
loadAccounting,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,14 +85,178 @@
|
|||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #address><ComingSoonPlaceholder /></template>
|
<!-- Onglet Adresse : saisie multi-adresses (blocs ajoutables). -->
|
||||||
<template v-if="canAccountingView" #accounting><ComingSoonPlaceholder /></template>
|
<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="isValidated('address')"
|
||||||
|
:errors="addressErrors[index]"
|
||||||
|
@update:model-value="(v) => addresses[index] = v"
|
||||||
|
@remove="askRemoveAddress(index)"
|
||||||
|
@degraded="onAddressDegraded"
|
||||||
|
/>
|
||||||
|
<div v-if="!isValidated('address')" 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.form.submit')"
|
||||||
|
:disabled="tabSubmitting || providerId === null"
|
||||||
|
@click="onSubmitAddresses"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<!-- Onglet Comptabilite (present uniquement 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"
|
||||||
|
/>
|
||||||
|
<!-- Banque : visible et obligatoire seulement si VIREMENT (RG-3.07). -->
|
||||||
|
<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.form.submit')"
|
||||||
|
:disabled="tabSubmitting || providerId === null"
|
||||||
|
@click="onSubmitAccounting"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</MalioTabList>
|
</MalioTabList>
|
||||||
|
|
||||||
<!-- Modal de confirmation generique (suppression d'un bloc contact). -->
|
<!-- Modal de confirmation generique (suppression d'un bloc contact). -->
|
||||||
@@ -120,11 +284,18 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { useProviderReferentials } from '~/modules/technique/composables/useProviderReferentials'
|
import { useProviderReferentials, type RefOption } from '~/modules/technique/composables/useProviderReferentials'
|
||||||
import { useProviderForm } from '~/modules/technique/composables/useProviderForm'
|
import { useProviderForm } from '~/modules/technique/composables/useProviderForm'
|
||||||
|
import {
|
||||||
|
isBankRequiredForPaymentType,
|
||||||
|
isRibRequiredForPaymentType,
|
||||||
|
} from '~/modules/technique/utils/forms/providerAccounting'
|
||||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||||
|
|
||||||
|
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
|
||||||
|
const SIREN_MASK = '#########'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -143,6 +314,7 @@ const referentials = useProviderReferentials()
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
main,
|
main,
|
||||||
|
providerId,
|
||||||
mainLocked,
|
mainLocked,
|
||||||
mainSubmitting,
|
mainSubmitting,
|
||||||
mainErrors,
|
mainErrors,
|
||||||
@@ -159,6 +331,22 @@ const {
|
|||||||
addContact,
|
addContact,
|
||||||
removeContact,
|
removeContact,
|
||||||
submitContacts,
|
submitContacts,
|
||||||
|
addresses,
|
||||||
|
addressErrors,
|
||||||
|
canAddAddress,
|
||||||
|
addAddress,
|
||||||
|
removeAddress,
|
||||||
|
submitAddresses,
|
||||||
|
accounting,
|
||||||
|
ribs,
|
||||||
|
accountingErrors,
|
||||||
|
ribErrors,
|
||||||
|
accountingReadonly,
|
||||||
|
setPaymentType,
|
||||||
|
canAddRib,
|
||||||
|
addRib,
|
||||||
|
removeRib,
|
||||||
|
submitAccounting,
|
||||||
} = useProviderForm()
|
} = useProviderForm()
|
||||||
|
|
||||||
/** Retour vers le repertoire prestataires (fleche d'en-tete). */
|
/** Retour vers le repertoire prestataires (fleche d'en-tete). */
|
||||||
@@ -175,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')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +397,93 @@ function askRemoveContact(index: number): void {
|
|||||||
askConfirm(t('technique.providers.form.confirmDelete.contact'), () => removeContact(index))
|
askConfirm(t('technique.providers.form.confirmDelete.contact'), () => removeContact(index))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Onglet Adresse ────────────────────────────────────────────────────────────
|
||||||
|
// Contacts deja persistes (IRI non nul), rattachables a une adresse (M2M). Le
|
||||||
|
// libelle reprend le nom complet, a defaut l'email.
|
||||||
|
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 ?? ''),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pays : France garantie en tete meme si /countries echoue (resilience ERP-102),
|
||||||
|
// pour rester preselectionnable par defaut sur chaque adresse.
|
||||||
|
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)
|
||||||
|
|
||||||
|
/** Avertit une seule fois quand l'autocompletion d'adresse bascule en degrade (RG-3.06). */
|
||||||
|
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'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Valide l'onglet Adresse ; redirige si c'est le dernier onglet du role. */
|
||||||
|
async function onSubmitAddresses(): Promise<void> {
|
||||||
|
const ok = await submitAddresses(error => toast.error({
|
||||||
|
title: t('technique.providers.toast.error'),
|
||||||
|
message: apiErrorMessage(error),
|
||||||
|
}))
|
||||||
|
if (ok) {
|
||||||
|
onTabSaved('address')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function askRemoveAddress(index: number): void {
|
||||||
|
askConfirm(t('technique.providers.form.confirmDelete.address'), () => removeAddress(index))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Onglet Comptabilite ───────────────────────────────────────────────────────
|
||||||
|
// Code stable du type de reglement selectionne (pour RG-3.07 / RG-3.08).
|
||||||
|
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))
|
||||||
|
|
||||||
|
// Les blocs RIB ne sont affiches que pour une LCR (RG-3.08).
|
||||||
|
const visibleRibs = computed(() => isRibRequired.value ? ribs.value : [])
|
||||||
|
|
||||||
|
/** Changement de type de reglement : propage les RG inter-champs (banque / RIB). */
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
function askRemoveRib(index: number): void {
|
||||||
|
askConfirm(t('technique.providers.form.confirmDelete.rib'), () => removeRib(index))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Valide l'onglet Comptabilite ; redirige si c'est le dernier onglet du role. */
|
||||||
|
async function onSubmitAccounting(): Promise<void> {
|
||||||
|
const ok = await submitAccounting(
|
||||||
|
isBankRequired.value,
|
||||||
|
isRibRequired.value,
|
||||||
|
error => toast.error({
|
||||||
|
title: t('technique.providers.toast.error'),
|
||||||
|
message: apiErrorMessage(error),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (ok) {
|
||||||
|
onTabSaved('accounting')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Modal de confirmation generique ─────────────────────────────────────────
|
// ── Modal de confirmation generique ─────────────────────────────────────────
|
||||||
const confirmModal = reactive({
|
const confirmModal = reactive({
|
||||||
open: false,
|
open: false,
|
||||||
@@ -229,5 +522,9 @@ const tabs = computed(() => tabKeys.value.map((key, index) => ({
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Echec du chargement des referentiels non bloquant : les selects restent vides.
|
// Echec du chargement des referentiels non bloquant : les selects restent vides.
|
||||||
referentials.loadMain().catch(() => {})
|
referentials.loadMain().catch(() => {})
|
||||||
|
// Referentiels comptables charges uniquement si l'onglet est accessible.
|
||||||
|
if (canAccountingView.value) {
|
||||||
|
referentials.loadAccounting().catch(() => {})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -81,3 +81,97 @@ export interface ProviderContactResponse {
|
|||||||
'@id'?: string
|
'@id'?: string
|
||||||
id: number
|
id: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Une adresse du prestataire (onglet Adresse, ERP-143). Version SIMPLIFIEE de
|
||||||
|
* `SupplierAddressFormDraft` (M2) : PAS de type d'adresse (Prospect/Depart/Rendu),
|
||||||
|
* PAS de bennes, PAS de prestation de triage. Champs postaux + M2M sites /
|
||||||
|
* categories / contacts (par IRI).
|
||||||
|
*/
|
||||||
|
export interface ProviderAddressFormDraft {
|
||||||
|
/** Id serveur une fois l'adresse creee (null tant que non persistee). */
|
||||||
|
id: number | null
|
||||||
|
/** Pays (chaine libre, defaut « France »). */
|
||||||
|
country: string
|
||||||
|
postalCode: string | null
|
||||||
|
city: string | null
|
||||||
|
street: string | null
|
||||||
|
streetComplement: string | null
|
||||||
|
/** IRI des categories rattachees (type PRESTATAIRE, RG-3.09 ; >= 1). */
|
||||||
|
categoryIris: string[]
|
||||||
|
/** IRI des sites rattaches a l'adresse (M2M `provider_address_site`, RG-3.05 ; >= 1). */
|
||||||
|
siteIris: string[]
|
||||||
|
/** IRI des contacts rattaches (= blocs Contact deja persistes de l'onglet Contact). */
|
||||||
|
contactIris: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fabrique une adresse vierge (France presaisi). */
|
||||||
|
export function emptyProviderAddress(): ProviderAddressFormDraft {
|
||||||
|
return {
|
||||||
|
id: null,
|
||||||
|
country: 'France',
|
||||||
|
postalCode: null,
|
||||||
|
city: null,
|
||||||
|
street: null,
|
||||||
|
streetComplement: null,
|
||||||
|
categoryIris: [],
|
||||||
|
siteIris: [],
|
||||||
|
contactIris: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reponse du POST /providers/{id}/addresses (id suffisant pour le suivi cote front). */
|
||||||
|
export interface ProviderAddressResponse {
|
||||||
|
id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Etat « plat » de l'onglet Comptabilite (groupe `provider:write:accounting`).
|
||||||
|
* Relations (TVA / delai / type de reglement / banque) portees par leur IRI.
|
||||||
|
*/
|
||||||
|
export interface ProviderAccountingDraft {
|
||||||
|
siren: string | null
|
||||||
|
accountNumber: string | null
|
||||||
|
tvaModeIri: string | null
|
||||||
|
nTva: string | null
|
||||||
|
paymentDelayIri: string | null
|
||||||
|
paymentTypeIri: string | null
|
||||||
|
/** Banque : requise et envoyee uniquement si Type de reglement = VIREMENT (RG-3.07). */
|
||||||
|
bankIri: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fabrique un onglet Comptabilite vierge. */
|
||||||
|
export function emptyProviderAccounting(): ProviderAccountingDraft {
|
||||||
|
return {
|
||||||
|
siren: null,
|
||||||
|
accountNumber: null,
|
||||||
|
tvaModeIri: null,
|
||||||
|
nTva: null,
|
||||||
|
paymentDelayIri: null,
|
||||||
|
paymentTypeIri: null,
|
||||||
|
bankIri: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Un RIB du prestataire (sous-collection comptable, obligatoire si Type = LCR — RG-3.08). */
|
||||||
|
export interface ProviderRibFormDraft {
|
||||||
|
id: number | null
|
||||||
|
label: string | null
|
||||||
|
bic: string | null
|
||||||
|
iban: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fabrique un RIB vierge. */
|
||||||
|
export function emptyProviderRib(): ProviderRibFormDraft {
|
||||||
|
return {
|
||||||
|
id: null,
|
||||||
|
label: null,
|
||||||
|
bic: null,
|
||||||
|
iban: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reponse du POST /providers/{id}/ribs (id suffisant pour le suivi cote front). */
|
||||||
|
export interface ProviderRibResponse {
|
||||||
|
id: number
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
buildProviderAccountingPayload,
|
||||||
|
buildProviderRibPayload,
|
||||||
|
isBankRequiredForPaymentType,
|
||||||
|
isRibBlank,
|
||||||
|
isRibComplete,
|
||||||
|
isRibRequiredForPaymentType,
|
||||||
|
} from '../providerAccounting'
|
||||||
|
import { emptyProviderAccounting, emptyProviderRib } from '~/modules/technique/types/providerForm'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helpers purs de l'onglet Comptabilite prestataire (ERP-144) : RG inter-champs
|
||||||
|
* RG-3.07 (banque si VIREMENT) / RG-3.08 (RIB si LCR) + construction des payloads.
|
||||||
|
*/
|
||||||
|
describe('providerAccounting helpers', () => {
|
||||||
|
describe('RG-3.07 / RG-3.08 — type de reglement', () => {
|
||||||
|
it('banque requise uniquement pour VIREMENT', () => {
|
||||||
|
expect(isBankRequiredForPaymentType('VIREMENT')).toBe(true)
|
||||||
|
expect(isBankRequiredForPaymentType('LCR')).toBe(false)
|
||||||
|
expect(isBankRequiredForPaymentType('CHEQUE')).toBe(false)
|
||||||
|
expect(isBankRequiredForPaymentType(null)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('RIB requis uniquement pour LCR', () => {
|
||||||
|
expect(isRibRequiredForPaymentType('LCR')).toBe(true)
|
||||||
|
expect(isRibRequiredForPaymentType('VIREMENT')).toBe(false)
|
||||||
|
expect(isRibRequiredForPaymentType(null)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isRibBlank / isRibComplete', () => {
|
||||||
|
it('un RIB vierge est vide et incomplet', () => {
|
||||||
|
expect(isRibBlank(emptyProviderRib())).toBe(true)
|
||||||
|
expect(isRibComplete(emptyProviderRib())).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('un RIB partiel n\'est ni vide ni complet', () => {
|
||||||
|
const rib = { ...emptyProviderRib(), iban: 'FR76...' }
|
||||||
|
expect(isRibBlank(rib)).toBe(false)
|
||||||
|
expect(isRibComplete(rib)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('un RIB avec libelle + BIC + IBAN est complet', () => {
|
||||||
|
const rib = { ...emptyProviderRib(), label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' }
|
||||||
|
expect(isRibComplete(rib)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildProviderAccountingPayload (RG-3.07)', () => {
|
||||||
|
it('envoie la banque si requise (VIREMENT)', () => {
|
||||||
|
const payload = buildProviderAccountingPayload({
|
||||||
|
...emptyProviderAccounting(),
|
||||||
|
paymentTypeIri: '/api/payment_types/3',
|
||||||
|
bankIri: '/api/banks/2',
|
||||||
|
}, true)
|
||||||
|
expect(payload.bank).toBe('/api/banks/2')
|
||||||
|
expect(payload.paymentType).toBe('/api/payment_types/3')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('force la banque a null si non requise (hors VIREMENT)', () => {
|
||||||
|
const payload = buildProviderAccountingPayload({
|
||||||
|
...emptyProviderAccounting(),
|
||||||
|
bankIri: '/api/banks/2',
|
||||||
|
}, false)
|
||||||
|
expect(payload.bank).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildProviderRibPayload', () => {
|
||||||
|
it('omet les champs requis vides (NotBlank back joue sur le champ)', () => {
|
||||||
|
const payload = buildProviderRibPayload(emptyProviderRib())
|
||||||
|
expect(payload).not.toHaveProperty('label')
|
||||||
|
expect(payload).not.toHaveProperty('bic')
|
||||||
|
expect(payload).not.toHaveProperty('iban')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('conserve les champs remplis', () => {
|
||||||
|
const payload = buildProviderRibPayload({ ...emptyProviderRib(), label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' })
|
||||||
|
expect(payload).toEqual({ label: 'Compte', bic: 'BNPAFRPP', iban: 'FR76...' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
buildProviderAddressPayload,
|
||||||
|
isProviderAddressValid,
|
||||||
|
} from '../providerAddress'
|
||||||
|
import { emptyProviderAddress } from '~/modules/technique/types/providerForm'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helpers purs de l'onglet Adresse prestataire (ERP-143). RG-3.05 (>= 1 site) et
|
||||||
|
* construction du payload de sous-ressource (relations en IRI, requis vides omis,
|
||||||
|
* pas de type d'adresse / bennes / triage — difference M2).
|
||||||
|
*/
|
||||||
|
describe('providerAddress helpers', () => {
|
||||||
|
const SITE = '/api/sites/1'
|
||||||
|
const CAT = '/api/categories/7'
|
||||||
|
|
||||||
|
describe('isProviderAddressValid (RG-3.05 / RG-3.09)', () => {
|
||||||
|
it('false sans site', () => {
|
||||||
|
const address = { ...emptyProviderAddress(), categoryIris: [CAT] }
|
||||||
|
expect(isProviderAddressValid(address)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('false sans categorie', () => {
|
||||||
|
const address = { ...emptyProviderAddress(), siteIris: [SITE] }
|
||||||
|
expect(isProviderAddressValid(address)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('true avec au moins un site ET une categorie', () => {
|
||||||
|
const address = { ...emptyProviderAddress(), siteIris: [SITE], categoryIris: [CAT] }
|
||||||
|
expect(isProviderAddressValid(address)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildProviderAddressPayload', () => {
|
||||||
|
it('mappe les relations en IRI et n\'embarque PAS type/bennes/triage (difference M2)', () => {
|
||||||
|
const payload = buildProviderAddressPayload({
|
||||||
|
...emptyProviderAddress(),
|
||||||
|
postalCode: '86100',
|
||||||
|
city: 'Châtellerault',
|
||||||
|
street: '1 rue du Test',
|
||||||
|
siteIris: [SITE],
|
||||||
|
categoryIris: [CAT],
|
||||||
|
contactIris: ['/api/provider_contacts/9'],
|
||||||
|
})
|
||||||
|
expect(payload).toEqual({
|
||||||
|
country: 'France',
|
||||||
|
postalCode: '86100',
|
||||||
|
city: 'Châtellerault',
|
||||||
|
street: '1 rue du Test',
|
||||||
|
streetComplement: null,
|
||||||
|
categories: [CAT],
|
||||||
|
sites: [SITE],
|
||||||
|
contacts: ['/api/provider_contacts/9'],
|
||||||
|
})
|
||||||
|
expect(payload).not.toHaveProperty('addressType')
|
||||||
|
expect(payload).not.toHaveProperty('bennes')
|
||||||
|
expect(payload).not.toHaveProperty('triageProvider')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omet les scalaires requis vides (NotBlank back joue sur le champ)', () => {
|
||||||
|
const payload = buildProviderAddressPayload({
|
||||||
|
...emptyProviderAddress(),
|
||||||
|
siteIris: [SITE],
|
||||||
|
categoryIris: [CAT],
|
||||||
|
})
|
||||||
|
expect(payload).not.toHaveProperty('postalCode')
|
||||||
|
expect(payload).not.toHaveProperty('city')
|
||||||
|
expect(payload).not.toHaveProperty('street')
|
||||||
|
// streetComplement n'est PAS requis -> reste present a null.
|
||||||
|
expect(payload).toHaveProperty('streetComplement', null)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Helpers purs de l'onglet Comptabilite prestataire (M3 Technique, ERP-144) —
|
||||||
|
* miroir SIMPLIFIE des regles M2, reimplemente cote module Technique (regle
|
||||||
|
* ABSOLUE n°1 : pas d'import inter-module). Portent les RG inter-champs RG-3.07
|
||||||
|
* (banque si VIREMENT) et RG-3.08 (RIB si LCR), testables sans Vue ni API.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ProviderAccountingDraft,
|
||||||
|
ProviderRibFormDraft,
|
||||||
|
} from '~/modules/technique/types/providerForm'
|
||||||
|
|
||||||
|
/** Code pivot du type de reglement imposant une banque (RG-3.07). */
|
||||||
|
const PAYMENT_TYPE_VIREMENT = 'VIREMENT'
|
||||||
|
/** Code pivot du type de reglement imposant au moins un RIB (RG-3.08). */
|
||||||
|
const PAYMENT_TYPE_LCR = 'LCR'
|
||||||
|
|
||||||
|
/** Champs RIB obligatoires non nullable cote back (NotBlank) — omis si vides au POST. */
|
||||||
|
const RIB_REQUIRED_NON_NULLABLE_KEYS = ['label', 'bic', 'iban'] as const
|
||||||
|
|
||||||
|
/** Vrai si une chaine porte au moins un caractere non-espace. */
|
||||||
|
function isFilled(value: string | null | undefined): boolean {
|
||||||
|
return value !== null && value !== undefined && value.trim() !== ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RG-3.07 : la banque n'est requise/visible que pour un reglement par VIREMENT. */
|
||||||
|
export function isBankRequiredForPaymentType(code: string | null | undefined): boolean {
|
||||||
|
return code === PAYMENT_TYPE_VIREMENT
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RG-3.08 : au moins un RIB n'est requis que pour un reglement par LCR. */
|
||||||
|
export function isRibRequiredForPaymentType(code: string | null | undefined): boolean {
|
||||||
|
return code === PAYMENT_TYPE_LCR
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vrai si AUCUN champ du bloc RIB n'est rempli (amorce vide a ignorer au submit). */
|
||||||
|
export function isRibBlank(rib: ProviderRibFormDraft): boolean {
|
||||||
|
return ![rib.label, rib.bic, rib.iban].some(isFilled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vrai si les 3 champs du RIB sont remplis (gating « + RIB »). */
|
||||||
|
export function isRibComplete(rib: ProviderRibFormDraft): boolean {
|
||||||
|
return isFilled(rib.label) && isFilled(rib.bic) && isFilled(rib.iban)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload du PATCH comptable (groupe `provider:write:accounting`). Les relations
|
||||||
|
* sont en IRI ; la banque n'est envoyee que si elle est requise (RG-3.07), sinon
|
||||||
|
* `null` (le back vide la relation hors VIREMENT).
|
||||||
|
*/
|
||||||
|
export function buildProviderAccountingPayload(
|
||||||
|
accounting: ProviderAccountingDraft,
|
||||||
|
isBankRequired: boolean,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
siren: accounting.siren || null,
|
||||||
|
accountNumber: accounting.accountNumber || null,
|
||||||
|
tvaMode: accounting.tvaModeIri,
|
||||||
|
nTva: accounting.nTva || null,
|
||||||
|
paymentDelay: accounting.paymentDelayIri,
|
||||||
|
paymentType: accounting.paymentTypeIri,
|
||||||
|
bank: isBankRequired ? accounting.bankIri : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload d'un RIB (sous-ressource, groupe `provider:write:accounting`). Les
|
||||||
|
* champs requis vides sont omis a la creation pour que la 422 NotBlank porte sur
|
||||||
|
* le champ.
|
||||||
|
*/
|
||||||
|
export function buildProviderRibPayload(rib: ProviderRibFormDraft): Record<string, unknown> {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
label: rib.label,
|
||||||
|
bic: rib.bic,
|
||||||
|
iban: rib.iban,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of RIB_REQUIRED_NON_NULLABLE_KEYS) {
|
||||||
|
const value = payload[key]
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
delete payload[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Helpers purs de l'onglet Adresse prestataire (M3 Technique, ERP-143) — miroir
|
||||||
|
* SIMPLIFIE de `supplierFormRules`/`supplierEdit` (M2), reimplemente cote module
|
||||||
|
* Technique (regle ABSOLUE n°1 : pas d'import inter-module). Testables sans Vue.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ProviderAddressFormDraft } from '~/modules/technique/types/providerForm'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Champs scalaires obligatoires non nullable cote back (NotBlank). A la creation
|
||||||
|
* (POST), on OMET du payload ceux qui sont vides pour que la 422 porte la
|
||||||
|
* violation NotBlank propre (sur le champ) plutot qu'une erreur de type.
|
||||||
|
*/
|
||||||
|
const REQUIRED_NON_NULLABLE_KEYS = ['postalCode', 'city', 'street'] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RG-3.05 (+ RG-3.09) : une adresse est « valide » pour autoriser l'ajout d'un
|
||||||
|
* nouveau bloc des qu'elle porte au moins un site ET au moins une categorie. Les
|
||||||
|
* scalaires (CP/ville/rue) restent valides par le back (422 inline).
|
||||||
|
*/
|
||||||
|
export function isProviderAddressValid(address: ProviderAddressFormDraft): boolean {
|
||||||
|
return address.siteIris.length >= 1 && address.categoryIris.length >= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload de la sous-ressource addresses (groupe `provider:write:addresses`).
|
||||||
|
* Relations M2M en IRI. Les scalaires requis vides sont omis a la creation (cf.
|
||||||
|
* REQUIRED_NON_NULLABLE_KEYS).
|
||||||
|
*/
|
||||||
|
export function buildProviderAddressPayload(address: ProviderAddressFormDraft): Record<string, unknown> {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
country: address.country,
|
||||||
|
postalCode: address.postalCode || null,
|
||||||
|
city: address.city || null,
|
||||||
|
street: address.street || null,
|
||||||
|
streetComplement: address.streetComplement || null,
|
||||||
|
categories: [...address.categoryIris],
|
||||||
|
sites: [...address.siteIris],
|
||||||
|
contacts: [...address.contactIris],
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of REQUIRED_NON_NULLABLE_KEYS) {
|
||||||
|
const value = payload[key]
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
delete payload[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload
|
||||||
|
}
|
||||||
@@ -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