Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29ee4e9fd0 | |||
| 1bc432e8fa | |||
| 1193832505 | |||
| 583d634a83 | |||
| ee1521384e | |||
| 79dffccc79 | |||
| 1ff335b3fe |
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.64'
|
app.version: '0.1.66'
|
||||||
|
|||||||
@@ -51,12 +51,20 @@
|
|||||||
"export": "Exporter",
|
"export": "Exporter",
|
||||||
"empty": "Aucun client pour l'instant.",
|
"empty": "Aucun client pour l'instant.",
|
||||||
"column": {
|
"column": {
|
||||||
"companyName": "Nom entreprise",
|
"companyName": "Nom",
|
||||||
"contact": "Contact principal",
|
|
||||||
"phone": "Téléphone principal",
|
|
||||||
"email": "Email principal",
|
|
||||||
"categories": "Catégories",
|
"categories": "Catégories",
|
||||||
"sites": "Site(s)"
|
"sites": "Site",
|
||||||
|
"lastActivity": "Dernière activité"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"title": "Filtres",
|
||||||
|
"search": "Recherche",
|
||||||
|
"categories": "Catégories",
|
||||||
|
"sites": "Sites",
|
||||||
|
"status": "Statut",
|
||||||
|
"archivedOnly": "Voir les archivés",
|
||||||
|
"apply": "Voir les résultats",
|
||||||
|
"reset": "Réinitialiser"
|
||||||
},
|
},
|
||||||
"tab": {
|
"tab": {
|
||||||
"information": "Information",
|
"information": "Information",
|
||||||
@@ -78,7 +86,8 @@
|
|||||||
"updateSuccess": "Client mis à jour avec succès",
|
"updateSuccess": "Client mis à jour avec succès",
|
||||||
"archiveSuccess": "Client archivé avec succès",
|
"archiveSuccess": "Client archivé avec succès",
|
||||||
"restoreSuccess": "Client restauré avec succès",
|
"restoreSuccess": "Client restauré avec succès",
|
||||||
"error": "Une erreur est survenue. Réessayez."
|
"error": "Une erreur est survenue. Réessayez.",
|
||||||
|
"exportError": "L'export du répertoire clients a échoué. Réessayez."
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"informationRequiredForCommercial": "Les informations de l'entreprise sont obligatoires pour le rôle Commerciale.",
|
"informationRequiredForCommercial": "Les informations de l'entreprise sont obligatoires pour le rôle Commerciale.",
|
||||||
@@ -90,6 +99,90 @@
|
|||||||
"phoneFormat": "Format de téléphone invalide (attendu : XX XX XX XX XX).",
|
"phoneFormat": "Format de téléphone invalide (attendu : XX XX XX XX XX).",
|
||||||
"emailFormat": "Format d'email invalide.",
|
"emailFormat": "Format d'email invalide.",
|
||||||
"addressCategoryForbidden": "Une catégorie « Distributeur » ou « Courtier » ne peut pas qualifier une adresse."
|
"addressCategoryForbidden": "Une catégorie « Distributeur » ou « Courtier » ne peut pas qualifier une adresse."
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"title": "Ajouter un client",
|
||||||
|
"submit": "Valider",
|
||||||
|
"duplicateCompany": "Un client portant ce nom de société existe déjà.",
|
||||||
|
"main": {
|
||||||
|
"companyName": "Nom de l'entreprise",
|
||||||
|
"firstName": "Prénom",
|
||||||
|
"lastName": "Nom",
|
||||||
|
"email": "Email",
|
||||||
|
"phonePrimary": "Téléphone",
|
||||||
|
"phoneSecondary": "Téléphone (2)",
|
||||||
|
"addPhone": "Ajouter un numéro",
|
||||||
|
"categories": "Catégories",
|
||||||
|
"relation": "Relation",
|
||||||
|
"relationNone": "Aucune",
|
||||||
|
"relationDistributor": "Distributeur",
|
||||||
|
"relationBroker": "Courtier",
|
||||||
|
"distributorName": "Nom du distributeur",
|
||||||
|
"brokerName": "Nom du courtier",
|
||||||
|
"triageService": "Prestation de triage"
|
||||||
|
},
|
||||||
|
"information": {
|
||||||
|
"description": "Description",
|
||||||
|
"competitors": "Concurrents",
|
||||||
|
"foundedAt": "Date de création",
|
||||||
|
"employeesCount": "Nombre de salariés",
|
||||||
|
"revenueAmount": "Chiffre d'affaires",
|
||||||
|
"profitAmount": "Résultat",
|
||||||
|
"directorName": "Dirigeant"
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"title": "Contact {n}",
|
||||||
|
"lastName": "Nom",
|
||||||
|
"firstName": "Prénom",
|
||||||
|
"jobTitle": "Fonction",
|
||||||
|
"email": "Email",
|
||||||
|
"phonePrimary": "Téléphone",
|
||||||
|
"phoneSecondary": "Téléphone (2)",
|
||||||
|
"addPhone": "Ajouter un numéro",
|
||||||
|
"remove": "Supprimer le contact",
|
||||||
|
"add": "Nouveau contact"
|
||||||
|
},
|
||||||
|
"address": {
|
||||||
|
"title": "Adresse {n}",
|
||||||
|
"prospect": "Prospect",
|
||||||
|
"delivery": "Adresse de livraison",
|
||||||
|
"billing": "Facturation",
|
||||||
|
"categories": "Catégorie",
|
||||||
|
"country": "Pays",
|
||||||
|
"postalCode": "Code postal",
|
||||||
|
"city": "Ville",
|
||||||
|
"street": "Adresse",
|
||||||
|
"streetComplement": "Adresse complémentaire",
|
||||||
|
"sites": "Sites Starseed",
|
||||||
|
"contacts": "Contact(s) rattaché(s)",
|
||||||
|
"billingEmail": "Email de facturation",
|
||||||
|
"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",
|
||||||
|
"ribTitle": "RIB {n}",
|
||||||
|
"ribLabel": "Libellé",
|
||||||
|
"ribBic": "BIC",
|
||||||
|
"ribIban": "IBAN",
|
||||||
|
"addRib": "Ajouter un RIB",
|
||||||
|
"removeRib": "Supprimer le RIB"
|
||||||
|
},
|
||||||
|
"confirmDelete": {
|
||||||
|
"title": "Confirmer la suppression",
|
||||||
|
"contact": "Supprimer ce contact ?",
|
||||||
|
"address": "Supprimer cette adresse ?",
|
||||||
|
"rib": "Supprimer ce RIB ?",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"confirm": "Confirmer"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
<template>
|
||||||
|
<div class="rounded-md border border-neutral-200 bg-white p-6">
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-bold">{{ title }}</h3>
|
||||||
|
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
||||||
|
<MalioButtonIcon
|
||||||
|
v-if="removable && !readonly"
|
||||||
|
icon="mdi:trash-can-outline"
|
||||||
|
variant="ghost"
|
||||||
|
v-bind="{ ariaLabel: t('commercial.clients.form.address.remove') }"
|
||||||
|
@click="$emit('remove')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Usage de l'adresse : Prospect exclusif de Livraison/Facturation
|
||||||
|
(RG-1.06/07/08). On masque l'option incompatible selon l'etat. -->
|
||||||
|
<div class="mb-4 flex flex-wrap gap-6">
|
||||||
|
<MalioCheckbox
|
||||||
|
v-if="canSelectProspect(model)"
|
||||||
|
:model-value="model.isProspect"
|
||||||
|
:label="t('commercial.clients.form.address.prospect')"
|
||||||
|
:readonly="readonly"
|
||||||
|
@update:model-value="(v: boolean) => toggleFlag('isProspect', v)"
|
||||||
|
/>
|
||||||
|
<MalioCheckbox
|
||||||
|
v-if="canSelectDeliveryOrBilling(model)"
|
||||||
|
:model-value="model.isDelivery"
|
||||||
|
:label="t('commercial.clients.form.address.delivery')"
|
||||||
|
:readonly="readonly"
|
||||||
|
@update:model-value="(v: boolean) => toggleFlag('isDelivery', v)"
|
||||||
|
/>
|
||||||
|
<MalioCheckbox
|
||||||
|
v-if="canSelectDeliveryOrBilling(model)"
|
||||||
|
:model-value="model.isBilling"
|
||||||
|
:label="t('commercial.clients.form.address.billing')"
|
||||||
|
:readonly="readonly"
|
||||||
|
@update:model-value="(v: boolean) => toggleFlag('isBilling', v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="model.categoryIris"
|
||||||
|
:options="categoryOptions"
|
||||||
|
:label="t('commercial.clients.form.address.categories')"
|
||||||
|
:display-tag="true"
|
||||||
|
:disabled="readonly"
|
||||||
|
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="model.country"
|
||||||
|
:options="countryOptions"
|
||||||
|
:label="t('commercial.clients.form.address.country')"
|
||||||
|
:disabled="readonly"
|
||||||
|
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="model.postalCode"
|
||||||
|
:label="t('commercial.clients.form.address.postalCode')"
|
||||||
|
:mask="POSTAL_CODE_MASK"
|
||||||
|
:readonly="readonly"
|
||||||
|
@update:model-value="onPostalCodeChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Ville : MalioSelect alimente par le code postal (BAN). En mode
|
||||||
|
degrade (service indisponible), bascule en saisie libre. -->
|
||||||
|
<MalioSelect
|
||||||
|
v-if="!degraded"
|
||||||
|
:model-value="model.city"
|
||||||
|
:options="cityOptions"
|
||||||
|
:label="t('commercial.clients.form.address.city')"
|
||||||
|
:disabled="readonly"
|
||||||
|
empty-option-label=""
|
||||||
|
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-else
|
||||||
|
:model-value="model.city"
|
||||||
|
:label="t('commercial.clients.form.address.city')"
|
||||||
|
:readonly="readonly"
|
||||||
|
@update:model-value="(v: string) => update('city', v)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Adresse : saisie assistee (BAN) ou libre en mode degrade. -->
|
||||||
|
<MalioInputAutocomplete
|
||||||
|
v-if="!degraded"
|
||||||
|
:model-value="model.street"
|
||||||
|
:options="addressOptions"
|
||||||
|
:loading="addressLoading"
|
||||||
|
:min-search-length="3"
|
||||||
|
:label="t('commercial.clients.form.address.street')"
|
||||||
|
:readonly="readonly"
|
||||||
|
@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('commercial.clients.form.address.street')"
|
||||||
|
:readonly="readonly"
|
||||||
|
@update:model-value="(v: string) => update('street', v)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="model.streetComplement"
|
||||||
|
:label="t('commercial.clients.form.address.streetComplement')"
|
||||||
|
:readonly="readonly"
|
||||||
|
@update:model-value="(v: string) => update('streetComplement', v)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="model.siteIris"
|
||||||
|
:options="siteOptions"
|
||||||
|
:label="t('commercial.clients.form.address.sites')"
|
||||||
|
:display-tag="true"
|
||||||
|
:disabled="readonly"
|
||||||
|
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="model.contactIris"
|
||||||
|
:options="contactOptions"
|
||||||
|
:label="t('commercial.clients.form.address.contacts')"
|
||||||
|
:display-tag="true"
|
||||||
|
:disabled="readonly"
|
||||||
|
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Email de facturation : visible/obligatoire seulement si Facturation
|
||||||
|
est coche (RG-1.11). -->
|
||||||
|
<MalioInputText
|
||||||
|
v-if="isBillingEmailRequired(model)"
|
||||||
|
:model-value="model.billingEmail"
|
||||||
|
:label="t('commercial.clients.form.address.billingEmail')"
|
||||||
|
:required="true"
|
||||||
|
:readonly="readonly"
|
||||||
|
@update:model-value="(v: string) => update('billingEmail', v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
applyProspectExclusivity,
|
||||||
|
canSelectDeliveryOrBilling,
|
||||||
|
canSelectProspect,
|
||||||
|
isBillingEmailRequired,
|
||||||
|
type AddressFlagsDraft,
|
||||||
|
} from '~/modules/commercial/utils/clientFormRules'
|
||||||
|
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
|
||||||
|
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useClientReferentials'
|
||||||
|
import type { AddressFormDraft } from '~/modules/commercial/types/clientForm'
|
||||||
|
|
||||||
|
// Masque code postal FR : 5 chiffres.
|
||||||
|
const POSTAL_CODE_MASK = '#####'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** Brouillon de l'adresse (v-model). */
|
||||||
|
modelValue: AddressFormDraft
|
||||||
|
title: string
|
||||||
|
/** Categories autorisees sur une adresse (DISTRIBUTEUR/COURTIER exclus, RG-1.29). */
|
||||||
|
categoryOptions: CategoryOption[]
|
||||||
|
/** 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
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: AddressFormDraft]
|
||||||
|
'remove': []
|
||||||
|
/** Emis une fois quand le service d'autocompletion bascule en indisponible. */
|
||||||
|
'degraded': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const autocomplete = useAddressAutocomplete()
|
||||||
|
|
||||||
|
const model = computed(() => props.modelValue)
|
||||||
|
|
||||||
|
// Mode degrade : service BAN indisponible → Ville/Adresse en saisie libre.
|
||||||
|
const degraded = ref(false)
|
||||||
|
const cityOptions = ref<RefOption[]>([])
|
||||||
|
const addressOptions = ref<RefOption[]>([])
|
||||||
|
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 AddressFormDraft>(field: K, value: AddressFormDraft[K]): void {
|
||||||
|
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applique l'exclusivite Prospect / (Livraison|Facturation) au changement. */
|
||||||
|
function toggleFlag(field: keyof AddressFlagsDraft, value: boolean): void {
|
||||||
|
const flags = applyProspectExclusivity(
|
||||||
|
{ isProspect: model.value.isProspect, isDelivery: model.value.isDelivery, isBilling: model.value.isBilling },
|
||||||
|
field,
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
emit('update:modelValue', { ...props.modelValue, ...flags })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bascule définitivement en mode degrade et previent le parent (toast unique). */
|
||||||
|
function enterDegraded(): void {
|
||||||
|
if (!degraded.value) {
|
||||||
|
degraded.value = true
|
||||||
|
emit('degraded')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */
|
||||||
|
async function onPostalCodeChange(value: string): Promise<void> {
|
||||||
|
update('postalCode', value)
|
||||||
|
|
||||||
|
if (degraded.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const digits = (value ?? '').replace(/\D/g, '')
|
||||||
|
if (digits.length < 5) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const suggestions = await autocomplete.searchCity(digits)
|
||||||
|
cityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
enterDegraded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recherche d'adresse assistee (event de MalioInputAutocomplete). */
|
||||||
|
async function onAddressSearch(query: string): Promise<void> {
|
||||||
|
if (degraded.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addressLoading.value = true
|
||||||
|
try {
|
||||||
|
const postalCode = (model.value.postalCode ?? '').replace(/\D/g, '') || undefined
|
||||||
|
const suggestions = await autocomplete.searchAddress(query, postalCode)
|
||||||
|
lastAddressSuggestions = suggestions
|
||||||
|
addressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
enterDegraded()
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
addressLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selection d'une suggestion d'adresse → remplit rue + ville + CP.
|
||||||
|
* Le type d'option suit le contrat MalioInputAutocomplete ({ label, value }).
|
||||||
|
*/
|
||||||
|
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,102 @@
|
|||||||
|
<template>
|
||||||
|
<div class="rounded-md border border-neutral-200 bg-white p-6">
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-bold">{{ title }}</h3>
|
||||||
|
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee
|
||||||
|
si non supprimable (1er bloc obligatoire RG-1.14) ou en lecture seule. -->
|
||||||
|
<!-- ariaLabel passe via v-bind objet : la prop du composant est
|
||||||
|
camelCase (aria-* serait traite en attribut HTML, jamais en prop). -->
|
||||||
|
<MalioButtonIcon
|
||||||
|
v-if="removable && !readonly"
|
||||||
|
icon="mdi:trash-can-outline"
|
||||||
|
variant="ghost"
|
||||||
|
v-bind="{ ariaLabel: t('commercial.clients.form.contact.remove') }"
|
||||||
|
@click="$emit('remove')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="model.lastName"
|
||||||
|
:label="t('commercial.clients.form.contact.lastName')"
|
||||||
|
:readonly="readonly"
|
||||||
|
@update:model-value="(v: string) => update('lastName', v)"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="model.firstName"
|
||||||
|
:label="t('commercial.clients.form.contact.firstName')"
|
||||||
|
:readonly="readonly"
|
||||||
|
@update:model-value="(v: string) => update('firstName', v)"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
:model-value="model.jobTitle"
|
||||||
|
:label="t('commercial.clients.form.contact.jobTitle')"
|
||||||
|
:readonly="readonly"
|
||||||
|
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||||
|
/>
|
||||||
|
<MalioInputEmail
|
||||||
|
:model-value="model.email"
|
||||||
|
:label="t('commercial.clients.form.contact.email')"
|
||||||
|
:readonly="readonly"
|
||||||
|
@update:model-value="(v: string) => update('email', v)"
|
||||||
|
/>
|
||||||
|
<MalioInputPhone
|
||||||
|
:model-value="model.phonePrimary"
|
||||||
|
:label="t('commercial.clients.form.contact.phonePrimary')"
|
||||||
|
:mask="PHONE_MASK"
|
||||||
|
:readonly="readonly"
|
||||||
|
:addable="!model.hasSecondaryPhone && !readonly"
|
||||||
|
:add-button-label="t('commercial.clients.form.contact.addPhone')"
|
||||||
|
@update:model-value="(v: string) => update('phonePrimary', v)"
|
||||||
|
@add="revealSecondaryPhone"
|
||||||
|
/>
|
||||||
|
<MalioInputPhone
|
||||||
|
v-if="model.hasSecondaryPhone"
|
||||||
|
:model-value="model.phoneSecondary"
|
||||||
|
:label="t('commercial.clients.form.contact.phoneSecondary')"
|
||||||
|
:mask="PHONE_MASK"
|
||||||
|
:readonly="readonly"
|
||||||
|
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ContactFormDraft } from '~/modules/commercial/types/clientForm'
|
||||||
|
|
||||||
|
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste
|
||||||
|
// serveur, cf. formatPhoneFR re-applique a la valeur renvoyee).
|
||||||
|
const PHONE_MASK = '## ## ## ## ##'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** Brouillon du contact (v-model). */
|
||||||
|
modelValue: ContactFormDraft
|
||||||
|
/** Titre du bloc (ex: « Contact 1 »). */
|
||||||
|
title: string
|
||||||
|
/** Affiche l'icone de suppression (1er bloc non supprimable, RG-1.14). */
|
||||||
|
removable?: boolean
|
||||||
|
/** Bloc en lecture seule (onglet valide). */
|
||||||
|
readonly?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: ContactFormDraft]
|
||||||
|
'remove': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// Alias local pour la lisibilite du template.
|
||||||
|
const model = computed(() => props.modelValue)
|
||||||
|
|
||||||
|
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
|
||||||
|
function update<K extends keyof ContactFormDraft>(field: K, value: ContactFormDraft[K]): void {
|
||||||
|
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Revele le 2e numero (RG-1.02/1.20 : max 1 secondaire, le « + » disparait). */
|
||||||
|
function revealSecondaryPhone(): void {
|
||||||
|
emit('update:modelValue', { ...props.modelValue, hasSecondaryPhone: true })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<template>
|
||||||
|
<!--
|
||||||
|
Placeholder des onglets non encore implementes (Transport, Statistiques,
|
||||||
|
Rapports, Echanges). Frame vide blanche : aucun champ, aucun bouton,
|
||||||
|
aucun message « En cours » (decision Tristan 28/05). L'orchestrateur passe
|
||||||
|
automatiquement a l'onglet suivant — ce composant n'est qu'une coquille
|
||||||
|
visuelle reutilisee par 1.11/1.12.
|
||||||
|
-->
|
||||||
|
<div class="min-h-[240px] rounded-md bg-white" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Composant purement presentationnel : aucune prop, aucun event.
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import type { HydraCollection } from '~/shared/utils/api'
|
||||||
|
import type { Client } from '../useClientsRepository'
|
||||||
|
|
||||||
|
// `useApi` est un auto-import Nuxt : on le stubbe globalement pour intercepter
|
||||||
|
// les appels declenches par usePaginatedList (que useClientsRepository enveloppe)
|
||||||
|
// et controler les reponses. Meme pattern que useCategoriesAdmin.spec.ts.
|
||||||
|
const mockGet = vi.hoisted(() => vi.fn())
|
||||||
|
vi.stubGlobal('useApi', () => ({
|
||||||
|
get: mockGet,
|
||||||
|
post: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
patch: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Import APRES le stub pour que useApi soit bien resolu au top-level du module.
|
||||||
|
const { useClientsRepository } = await import('../useClientsRepository')
|
||||||
|
|
||||||
|
/** Envelope Hydra minimale (la liste reelle des membres importe peu ici). */
|
||||||
|
function makeHydra(total: number): HydraCollection<Client> {
|
||||||
|
return { totalItems: total, member: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useClientsRepository', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGet.mockReset()
|
||||||
|
// 25 items → 3 pages a 10/page : permet de tester la navigation page 2.
|
||||||
|
mockGet.mockResolvedValue(makeHydra(25))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cible la ressource /clients en page 1 par defaut', async () => {
|
||||||
|
const repo = useClientsRepository()
|
||||||
|
await repo.fetch()
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenLastCalledWith(
|
||||||
|
'/clients',
|
||||||
|
{ page: 1, itemsPerPage: 10 },
|
||||||
|
expect.objectContaining({ toast: false }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pousse les filtres du drawer (categories multi, sites, archives) et retombe en page 1', async () => {
|
||||||
|
const repo = useClientsRepository()
|
||||||
|
await repo.fetch()
|
||||||
|
await repo.goToPage(2)
|
||||||
|
expect(repo.currentPage.value).toBe(2)
|
||||||
|
|
||||||
|
await repo.setFilters(
|
||||||
|
{
|
||||||
|
search: 'acme',
|
||||||
|
'categoryCode[]': ['DISTRIBUTEUR', 'COURTIER'],
|
||||||
|
'siteId[]': ['1', '2'],
|
||||||
|
archivedOnly: true,
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(repo.currentPage.value).toBe(1)
|
||||||
|
expect(mockGet).toHaveBeenLastCalledWith(
|
||||||
|
'/clients',
|
||||||
|
{
|
||||||
|
search: 'acme',
|
||||||
|
'categoryCode[]': ['DISTRIBUTEUR', 'COURTIER'],
|
||||||
|
'siteId[]': ['1', '2'],
|
||||||
|
archivedOnly: true,
|
||||||
|
page: 1,
|
||||||
|
itemsPerPage: 10,
|
||||||
|
},
|
||||||
|
expect.objectContaining({ toast: false }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('repasse a une query propre apres reinitialisation des filtres', async () => {
|
||||||
|
const repo = useClientsRepository()
|
||||||
|
await repo.setFilters({ search: 'acme', archivedOnly: true }, { replace: true })
|
||||||
|
await repo.setFilters({}, { replace: true })
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenLastCalledWith(
|
||||||
|
'/clients',
|
||||||
|
{ page: 1, itemsPerPage: 10 },
|
||||||
|
expect.objectContaining({ toast: false }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge les referentiels (listes courtes) alimentant les selects de l'ecran
|
||||||
|
* « Ajouter un client » : categories, sites, modes de TVA, delais et types de
|
||||||
|
* reglement, banques, et les listes distributeurs / courtiers.
|
||||||
|
*
|
||||||
|
* Toutes les collections sont recuperees en entier via l'echappatoire prevue
|
||||||
|
* `?pagination=false` (referentiels de quelques dizaines d'entrees max), avec
|
||||||
|
* l'en-tete `Accept: application/ld+json` impose par API Platform 4 pour obtenir
|
||||||
|
* l'enveloppe Hydra (`member`). Les valeurs d'option sont les IRI Hydra (`@id`)
|
||||||
|
* pour pouvoir etre renvoyees telles quelles dans les payloads POST/PATCH
|
||||||
|
* (relations ManyToOne / ManyToMany).
|
||||||
|
*
|
||||||
|
* Etat 100 % local a l'instance (refs) — aucune persistance URL.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Option generique au format attendu par MalioSelect / MalioSelectCheckbox ({ label, value }). */
|
||||||
|
export interface RefOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Option de type de reglement enrichie de son code stable (RG-1.12 / RG-1.13). */
|
||||||
|
export interface PaymentTypeOption extends RefOption {
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Option de categorie enrichie de son code stable (filtrage RG-1.29 cote adresse). */
|
||||||
|
export interface CategoryOption extends RefOption {
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Option de client (distributeur / courtier) — value = IRI du client lie. */
|
||||||
|
export type ClientOption = RefOption
|
||||||
|
|
||||||
|
interface HydraMember {
|
||||||
|
'@id': string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryMember extends HydraMember {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SiteMember extends HydraMember {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReferentialMember extends HydraMember {
|
||||||
|
code: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClientMember extends HydraMember {
|
||||||
|
companyName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
|
||||||
|
|
||||||
|
export function useClientReferentials() {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const categories = ref<CategoryOption[]>([])
|
||||||
|
const sites = ref<RefOption[]>([])
|
||||||
|
const tvaModes = ref<RefOption[]>([])
|
||||||
|
const paymentDelays = ref<RefOption[]>([])
|
||||||
|
const paymentTypes = ref<PaymentTypeOption[]>([])
|
||||||
|
const banks = ref<RefOption[]>([])
|
||||||
|
const distributors = ref<ClientOption[]>([])
|
||||||
|
const brokers = ref<ClientOption[]>([])
|
||||||
|
|
||||||
|
/** Recupere une collection complete (pagination desactivee) en Hydra. */
|
||||||
|
async function fetchAll<T extends HydraMember>(
|
||||||
|
url: string,
|
||||||
|
query: Record<string, string | string[]> = {},
|
||||||
|
): Promise<T[]> {
|
||||||
|
const res = await api.get<{ member?: T[] }>(
|
||||||
|
url,
|
||||||
|
{ pagination: 'false', ...query },
|
||||||
|
{ headers: LD_JSON_HEADERS, toast: false },
|
||||||
|
)
|
||||||
|
return res.member ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge en parallele les referentiels communs (hors distributeurs/courtiers,
|
||||||
|
* charges a la demande selon la relation choisie). Les selects compta ne sont
|
||||||
|
* pertinents que si l'utilisateur a acces a l'onglet, mais le cout est
|
||||||
|
* negligeable et simplifie l'orchestration.
|
||||||
|
*/
|
||||||
|
async function loadCommon(): Promise<void> {
|
||||||
|
const [cats, sitesList, tva, delays, types, banksList] = await Promise.all([
|
||||||
|
fetchAll<CategoryMember>('/categories'),
|
||||||
|
fetchAll<SiteMember>('/sites'),
|
||||||
|
fetchAll<ReferentialMember>('/tva_modes'),
|
||||||
|
fetchAll<ReferentialMember>('/payment_delays'),
|
||||||
|
fetchAll<ReferentialMember>('/payment_types'),
|
||||||
|
fetchAll<ReferentialMember>('/banks'),
|
||||||
|
])
|
||||||
|
|
||||||
|
categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code }))
|
||||||
|
sites.value = sitesList.map(s => ({ value: s['@id'], label: s.name }))
|
||||||
|
tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label }))
|
||||||
|
paymentDelays.value = delays.map(d => ({ value: d['@id'], label: d.label }))
|
||||||
|
paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code }))
|
||||||
|
banks.value = banksList.map(b => ({ value: b['@id'], label: b.label }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liste des clients pouvant etre choisis comme distributeur (code DISTRIBUTEUR). */
|
||||||
|
async function loadDistributors(): Promise<void> {
|
||||||
|
if (distributors.value.length > 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const clients = await fetchAll<ClientMember>('/clients', { categoryCode: 'DISTRIBUTEUR' })
|
||||||
|
distributors.value = clients.map(c => ({ value: c['@id'], label: c.companyName }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liste des clients pouvant etre choisis comme courtier (code COURTIER). */
|
||||||
|
async function loadBrokers(): Promise<void> {
|
||||||
|
if (brokers.value.length > 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const clients = await fetchAll<ClientMember>('/clients', { categoryCode: 'COURTIER' })
|
||||||
|
brokers.value = clients.map(c => ({ value: c['@id'], label: c.companyName }))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
categories,
|
||||||
|
sites,
|
||||||
|
tvaModes,
|
||||||
|
paymentDelays,
|
||||||
|
paymentTypes,
|
||||||
|
banks,
|
||||||
|
distributors,
|
||||||
|
brokers,
|
||||||
|
loadCommon,
|
||||||
|
loadDistributors,
|
||||||
|
loadBrokers,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Site Starseed rattache a une adresse du client, tel qu'embarque en LISTE
|
||||||
|
* (groupe site:read) pour la colonne « Site(s) » du Repertoire (badges colores).
|
||||||
|
*/
|
||||||
|
export interface ClientSite {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Categorie rattachee au client, embarquee en LISTE (groupe category:read).
|
||||||
|
* Seul le `code` (stable, MAJUSCULE — ERP-78) est affiche dans la colonne
|
||||||
|
* « Catégories ». Les autres champs sont presents mais non utilises ici.
|
||||||
|
*/
|
||||||
|
export interface ClientCategory {
|
||||||
|
code: string
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vue MINIMALE d'un client pour le Repertoire (datatable). Volontairement
|
||||||
|
* partielle : seuls les champs des colonnes + l'id (navigation) sont types ici.
|
||||||
|
* Le detail complet (onglets) est hors perimetre de cet ecran (ERP-62).
|
||||||
|
*/
|
||||||
|
export interface Client {
|
||||||
|
id: number
|
||||||
|
companyName: string
|
||||||
|
categories: ClientCategory[]
|
||||||
|
sites: ClientSite[]
|
||||||
|
/** Date ISO de derniere modification (default:read) — colonne « Dernière activité ». */
|
||||||
|
updatedAt: string | null
|
||||||
|
isArchived: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repertoire clients (ERP-62) — simple enveloppe de `usePaginatedList<Client>`
|
||||||
|
* sur la ressource `/clients` (RG-13 : pagination serveur obligatoire ; jamais
|
||||||
|
* de chargement integral en memoire).
|
||||||
|
*
|
||||||
|
* Les filtres (recherche, categories, sites, archives) sont pilotes par la page
|
||||||
|
* via `setFilters` du composable partage — la remise en page 1 est garantie.
|
||||||
|
*
|
||||||
|
* Volontairement PAR INSTANCE (pas de singleton module-level) : l'etat tableau
|
||||||
|
* est propre a l'ecran Repertoire et meurt avec lui, comme tout consommateur de
|
||||||
|
* `usePaginatedList` (cf. sites.vue / categories.vue). Aucun reset au logout a
|
||||||
|
* gerer.
|
||||||
|
*/
|
||||||
|
export function useClientsRepository() {
|
||||||
|
return usePaginatedList<Client>({ url: '/clients' })
|
||||||
|
}
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<PageHeader>
|
||||||
|
{{ t('commercial.clients.title') }}
|
||||||
|
<template #actions>
|
||||||
|
<!-- gap-12 = 48px d'espacement entre Ajouter et Filtres. -->
|
||||||
|
<div class="flex items-center gap-12">
|
||||||
|
<MalioButton
|
||||||
|
v-if="canManage"
|
||||||
|
variant="secondary"
|
||||||
|
:label="t('commercial.clients.add')"
|
||||||
|
icon-name="mdi:add-bold"
|
||||||
|
icon-position="left"
|
||||||
|
@click="goToCreate"
|
||||||
|
/>
|
||||||
|
<!-- Bouton Filtres a DROITE d'Ajouter : meme design que
|
||||||
|
l'audit-log. Le compteur reflete les filtres actifs. -->
|
||||||
|
<MalioButton
|
||||||
|
v-if="canView"
|
||||||
|
variant="tertiary"
|
||||||
|
:label="filterButtonLabel"
|
||||||
|
icon-name="mdi:tune"
|
||||||
|
icon-position="left"
|
||||||
|
icon-size="24"
|
||||||
|
button-class="w-[184px] justify-start gap-4 text-black"
|
||||||
|
@click="openFilters"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<!-- Datatable branchee sur usePaginatedList via useClientsRepository :
|
||||||
|
pagination serveur, tri companyName ASC par defaut (cote back). -->
|
||||||
|
<MalioDataTable
|
||||||
|
:columns="columns"
|
||||||
|
:items="rows"
|
||||||
|
:total-items="totalItems"
|
||||||
|
:page="currentPage"
|
||||||
|
:per-page="itemsPerPage"
|
||||||
|
:per-page-options="itemsPerPageOptions"
|
||||||
|
row-clickable
|
||||||
|
table-class="table-fixed"
|
||||||
|
:empty-message="t('commercial.clients.empty')"
|
||||||
|
@row-click="onRowClick"
|
||||||
|
@update:page="goToPage"
|
||||||
|
@update:per-page="setItemsPerPage"
|
||||||
|
>
|
||||||
|
<!-- Categories : codes stables separes par une virgule (ERP-78). -->
|
||||||
|
<template #cell-categories="{ item }">
|
||||||
|
{{ formatCategories(item) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Sites : badges colores (name + color), agreges des adresses. -->
|
||||||
|
<template #cell-sites="{ item }">
|
||||||
|
<span class="flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
v-for="site in (item.sites as ClientSite[])"
|
||||||
|
:key="site.id"
|
||||||
|
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium text-white"
|
||||||
|
:style="{ backgroundColor: site.color }"
|
||||||
|
>
|
||||||
|
{{ site.name }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Derniere activite : date de derniere modification (updatedAt). -->
|
||||||
|
<template #cell-lastActivity="{ item }">
|
||||||
|
{{ formatLastActivity(item) }}
|
||||||
|
</template>
|
||||||
|
</MalioDataTable>
|
||||||
|
|
||||||
|
<div class="flex justify-center mt-6">
|
||||||
|
<MalioButton
|
||||||
|
v-if="canView"
|
||||||
|
variant="primary"
|
||||||
|
:label="t('commercial.clients.export')"
|
||||||
|
:disabled="exporting"
|
||||||
|
@click="exportXlsx"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Drawer de filtres : etat BROUILLON, applique uniquement au clic sur
|
||||||
|
« Appliquer ». Meme pattern que l'audit-log. Etat 100 % local, jamais
|
||||||
|
dans l'URL (regle ABSOLUE n°6). -->
|
||||||
|
<MalioDrawer
|
||||||
|
v-model="filterDrawerOpen"
|
||||||
|
drawer-class="max-w-[450px]"
|
||||||
|
body-class="p-0"
|
||||||
|
footer-class="justify-between border-t border-black p-6"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold uppercase">{{ t('commercial.clients.filters.title') }}</h2>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<MalioAccordion>
|
||||||
|
<!-- Recherche : nom societe + contact + email (param `search`). -->
|
||||||
|
<MalioAccordionItem :title="t('commercial.clients.filters.search')" value="search">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="draftSearch"
|
||||||
|
icon-name="mdi:magnify"
|
||||||
|
/>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
|
||||||
|
<!-- Categories : cases a cocher (multi). Valeur = code stable. -->
|
||||||
|
<MalioAccordionItem :title="t('commercial.clients.filters.categories')" value="categories">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<MalioCheckbox
|
||||||
|
v-for="opt in categoryOptions"
|
||||||
|
:id="`filter-category-${opt.value}`"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:model-value="draftCategoryCodes.includes(opt.value)"
|
||||||
|
@update:model-value="(val: boolean) => toggleCategory(opt.value, val)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
|
||||||
|
<!-- Sites : cases a cocher (multi). Valeur = id du site. -->
|
||||||
|
<MalioAccordionItem :title="t('commercial.clients.filters.sites')" value="sites">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<MalioCheckbox
|
||||||
|
v-for="opt in siteOptions"
|
||||||
|
:id="`filter-site-${opt.value}`"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:model-value="draftSiteIds.includes(opt.value)"
|
||||||
|
@update:model-value="(val: boolean) => toggleSite(opt.value, val)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
|
||||||
|
<!-- Statut : bool unique. Coche = archives uniquement, sinon actifs. -->
|
||||||
|
<MalioAccordionItem :title="t('commercial.clients.filters.status')" value="status">
|
||||||
|
<MalioCheckbox
|
||||||
|
id="filter-archived-only"
|
||||||
|
:label="t('commercial.clients.filters.archivedOnly')"
|
||||||
|
:model-value="draftArchivedOnly"
|
||||||
|
@update:model-value="(val: boolean) => draftArchivedOnly = val"
|
||||||
|
/>
|
||||||
|
</MalioAccordionItem>
|
||||||
|
</MalioAccordion>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
variant="tertiary"
|
||||||
|
:label="t('commercial.clients.filters.reset')"
|
||||||
|
button-class="w-m-btn-action"
|
||||||
|
@click="resetFilters"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('commercial.clients.filters.apply')"
|
||||||
|
button-class="w-[170px]"
|
||||||
|
@click="applyFilters"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MalioDrawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import type { Client, ClientSite } from '~/modules/commercial/composables/useClientsRepository'
|
||||||
|
|
||||||
|
interface FilterOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const api = useApi()
|
||||||
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
|
const { can } = usePermissions()
|
||||||
|
|
||||||
|
useHead({ title: t('commercial.clients.title') })
|
||||||
|
|
||||||
|
// Bouton « Ajouter » reserve a `manage` (POST /clients garde manage seul →
|
||||||
|
// Compta / Usine ne creent pas). « Exporter » et « Filtres » suivent `view`.
|
||||||
|
const canManage = computed(() => can('commercial.clients.manage'))
|
||||||
|
const canView = computed(() => can('commercial.clients.view'))
|
||||||
|
|
||||||
|
const {
|
||||||
|
items: clients,
|
||||||
|
totalItems,
|
||||||
|
currentPage,
|
||||||
|
itemsPerPage,
|
||||||
|
itemsPerPageOptions,
|
||||||
|
fetch: loadClients,
|
||||||
|
goToPage,
|
||||||
|
setItemsPerPage,
|
||||||
|
setFilters,
|
||||||
|
} = useClientsRepository()
|
||||||
|
|
||||||
|
// Mappe les clients en objets « plats » pour MalioDataTable (items typees
|
||||||
|
// Record<string, unknown>[]) : un objet litteral porte une signature d'index
|
||||||
|
// implicite, contrairement a l'interface Client. Meme pattern que sites.vue.
|
||||||
|
const rows = computed(() => clients.value.map(client => ({
|
||||||
|
id: client.id,
|
||||||
|
companyName: client.companyName,
|
||||||
|
categories: client.categories,
|
||||||
|
sites: client.sites,
|
||||||
|
updatedAt: client.updatedAt,
|
||||||
|
})))
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'companyName', label: t('commercial.clients.column.companyName') },
|
||||||
|
{ key: 'categories', label: t('commercial.clients.column.categories') },
|
||||||
|
{ key: 'sites', label: t('commercial.clients.column.sites') },
|
||||||
|
{ key: 'lastActivity', label: t('commercial.clients.column.lastActivity') },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Codes des categories du client, separes par une virgule (ERP-78). */
|
||||||
|
function formatCategories(item: Record<string, unknown>): string {
|
||||||
|
const categories = (item.categories as Client['categories']) ?? []
|
||||||
|
return categories.map(c => c.code).join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derniere activite : faute de suivi d'activite metier au M1, on affiche la
|
||||||
|
* date de derniere modification de la fiche (updatedAt, expose en liste via
|
||||||
|
* default:read). Format court francais jj/mm/aaaa.
|
||||||
|
*/
|
||||||
|
function formatLastActivity(item: Record<string, unknown>): string {
|
||||||
|
const value = item.updatedAt as string | null | undefined
|
||||||
|
if (!value) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Garde-fou date invalide : un updatedAt mal forme donnerait « Invalid Date ».
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleDateString('fr-FR')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clic sur une ligne → ecran Consultation (route a plat /clients/{id}). */
|
||||||
|
function onRowClick(item: Record<string, unknown>): void {
|
||||||
|
router.push(`/clients/${item.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToCreate(): void {
|
||||||
|
router.push('/clients/new')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Filtres (drawer) ────────────────────────────────────────────────────────
|
||||||
|
// Deux niveaux d'etat (pattern audit-log) :
|
||||||
|
// - APPLIED : pilote la liste/l'export + le compteur du bouton. Modifie
|
||||||
|
// uniquement au clic « Appliquer » / « Réinitialiser ».
|
||||||
|
// - DRAFT : edite librement dans le drawer ; recopie vers applied a la validation.
|
||||||
|
const filterDrawerOpen = ref(false)
|
||||||
|
|
||||||
|
const draftSearch = ref('')
|
||||||
|
const draftCategoryCodes = ref<string[]>([])
|
||||||
|
const draftSiteIds = ref<string[]>([])
|
||||||
|
const draftArchivedOnly = ref(false)
|
||||||
|
|
||||||
|
const appliedSearch = ref('')
|
||||||
|
const appliedCategoryCodes = ref<string[]>([])
|
||||||
|
const appliedSiteIds = ref<string[]>([])
|
||||||
|
const appliedArchivedOnly = ref(false)
|
||||||
|
|
||||||
|
// Options des selects multi, chargees une fois (referentiels courts).
|
||||||
|
const categoryOptions = ref<FilterOption[]>([])
|
||||||
|
const siteOptions = ref<FilterOption[]>([])
|
||||||
|
|
||||||
|
const activeFilterCount = computed(() => {
|
||||||
|
let count = 0
|
||||||
|
if (appliedSearch.value.trim() !== '') count++
|
||||||
|
if (appliedCategoryCodes.value.length > 0) count++
|
||||||
|
if (appliedSiteIds.value.length > 0) count++
|
||||||
|
if (appliedArchivedOnly.value) count++
|
||||||
|
return count
|
||||||
|
})
|
||||||
|
|
||||||
|
const filterButtonLabel = computed(() => {
|
||||||
|
const base = t('commercial.clients.filters.title')
|
||||||
|
return activeFilterCount.value > 0 ? `${base} (${activeFilterCount.value})` : base
|
||||||
|
})
|
||||||
|
|
||||||
|
// Recopie l'etat applique vers le brouillon puis ouvre le drawer : la
|
||||||
|
// reouverture reflete les filtres actifs.
|
||||||
|
function openFilters(): void {
|
||||||
|
draftSearch.value = appliedSearch.value
|
||||||
|
draftCategoryCodes.value = [...appliedCategoryCodes.value]
|
||||||
|
draftSiteIds.value = [...appliedSiteIds.value]
|
||||||
|
draftArchivedOnly.value = appliedArchivedOnly.value
|
||||||
|
filterDrawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCategory(code: string, selected: boolean): void {
|
||||||
|
draftCategoryCodes.value = selected
|
||||||
|
? [...draftCategoryCodes.value, code]
|
||||||
|
: draftCategoryCodes.value.filter(c => c !== code)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSite(id: string, selected: boolean): void {
|
||||||
|
draftSiteIds.value = selected
|
||||||
|
? [...draftSiteIds.value, id]
|
||||||
|
: draftSiteIds.value.filter(s => s !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit le payload de filtres serveur a partir de l'etat applique. Cles
|
||||||
|
* `categoryCode[]` / `siteId[]` pour que PHP les parse en tableaux (OR cote back).
|
||||||
|
* Les filtres vides sont omis pour une query propre.
|
||||||
|
*/
|
||||||
|
function buildFilterPayload(): Record<string, string | string[] | boolean> {
|
||||||
|
const payload: Record<string, string | string[] | boolean> = {}
|
||||||
|
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
|
||||||
|
if (appliedCategoryCodes.value.length > 0) payload['categoryCode[]'] = [...appliedCategoryCodes.value]
|
||||||
|
if (appliedSiteIds.value.length > 0) payload['siteId[]'] = [...appliedSiteIds.value]
|
||||||
|
if (appliedArchivedOnly.value) payload.archivedOnly = true
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
// « Appliquer » : recopie brouillon → applied, pousse les filtres (retombe en
|
||||||
|
// page 1 via usePaginatedList) et ferme le drawer.
|
||||||
|
function applyFilters(): void {
|
||||||
|
appliedSearch.value = draftSearch.value.trim()
|
||||||
|
appliedCategoryCodes.value = [...draftCategoryCodes.value]
|
||||||
|
appliedSiteIds.value = [...draftSiteIds.value]
|
||||||
|
appliedArchivedOnly.value = draftArchivedOnly.value
|
||||||
|
|
||||||
|
setFilters(buildFilterPayload(), { replace: true })
|
||||||
|
filterDrawerOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// « Réinitialiser » : vide brouillon ET applied, recharge la liste complete.
|
||||||
|
// Le drawer reste ouvert pour montrer le formulaire vide.
|
||||||
|
function resetFilters(): void {
|
||||||
|
draftSearch.value = ''
|
||||||
|
draftCategoryCodes.value = []
|
||||||
|
draftSiteIds.value = []
|
||||||
|
draftArchivedOnly.value = false
|
||||||
|
|
||||||
|
appliedSearch.value = ''
|
||||||
|
appliedCategoryCodes.value = []
|
||||||
|
appliedSiteIds.value = []
|
||||||
|
appliedArchivedOnly.value = false
|
||||||
|
|
||||||
|
setFilters({}, { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Charge les referentiels du drawer (categories + sites) via ?pagination=false. */
|
||||||
|
async function loadFilterOptions(): Promise<void> {
|
||||||
|
const [cats, sites] = await Promise.all([
|
||||||
|
api.get<{ member?: Array<{ code: string, name: string }> }>(
|
||||||
|
'/categories',
|
||||||
|
{ pagination: 'false' },
|
||||||
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
|
),
|
||||||
|
api.get<{ member?: Array<{ id: number, name: string }> }>(
|
||||||
|
'/sites',
|
||||||
|
{ pagination: 'false' },
|
||||||
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
categoryOptions.value = (cats.member ?? []).map(c => ({ value: c.code, label: c.name }))
|
||||||
|
siteOptions.value = (sites.member ?? []).map(s => ({ value: String(s.id), label: s.name }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Export XLSX ─────────────────────────────────────────────────────────────
|
||||||
|
// Memes filtres que la vue. La colonne SIREN n'est dans le fichier que si
|
||||||
|
// l'utilisateur a accounting.view (gere cote back).
|
||||||
|
const exporting = ref(false)
|
||||||
|
|
||||||
|
async function exportXlsx(): Promise<void> {
|
||||||
|
if (exporting.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exporting.value = true
|
||||||
|
try {
|
||||||
|
// useApi type ses options en JSON ; l'export renvoie un binaire, donc on
|
||||||
|
// force responseType:'blob' (transmis tel quel a ofetch au runtime). Cast
|
||||||
|
// contenu faute d'overload blob sur le client partage — a generaliser via
|
||||||
|
// un ticket dedie si d'autres exports binaires arrivent.
|
||||||
|
const blob = await api.get<Blob>('/clients/export.xlsx', buildFilterPayload(), {
|
||||||
|
responseType: 'blob',
|
||||||
|
toast: false,
|
||||||
|
} as unknown as Parameters<typeof api.get>[2])
|
||||||
|
|
||||||
|
triggerDownload(blob, 'repertoire-clients.xlsx')
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
toast.error({
|
||||||
|
title: t('commercial.clients.toast.error'),
|
||||||
|
message: t('commercial.clients.toast.exportError'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
exporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Declenche le telechargement d'un blob via un lien temporaire. */
|
||||||
|
function triggerDownload(blob: Blob, filename: string): void {
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = filename
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
link.remove()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadClients()
|
||||||
|
// Echec du chargement des referentiels non bloquant : la liste s'affiche,
|
||||||
|
// l'utilisateur perd juste les options de filtre.
|
||||||
|
loadFilterOptions().catch(() => {
|
||||||
|
categoryOptions.value = []
|
||||||
|
siteOptions.value = []
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,891 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<PageHeader>{{ t('commercial.clients.form.title') }}</PageHeader>
|
||||||
|
|
||||||
|
<!-- ── Formulaire principal (pre-onglets) ─────────────────────────────
|
||||||
|
Sans validation de ce bloc, les onglets restent inaccessibles. Au
|
||||||
|
succes du POST, les champs passent en lecture seule et on bascule
|
||||||
|
automatiquement sur l'onglet Information. -->
|
||||||
|
<section class="mb-8 rounded-md border border-neutral-200 bg-white p-6">
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="main.companyName"
|
||||||
|
:label="t('commercial.clients.form.main.companyName')"
|
||||||
|
:required="true"
|
||||||
|
:readonly="mainLocked"
|
||||||
|
/>
|
||||||
|
<MalioInputEmail
|
||||||
|
v-model="main.email"
|
||||||
|
:label="t('commercial.clients.form.main.email')"
|
||||||
|
:required="true"
|
||||||
|
:readonly="mainLocked"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="main.lastName"
|
||||||
|
:label="t('commercial.clients.form.main.lastName')"
|
||||||
|
:readonly="mainLocked"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="main.firstName"
|
||||||
|
:label="t('commercial.clients.form.main.firstName')"
|
||||||
|
:readonly="mainLocked"
|
||||||
|
/>
|
||||||
|
<MalioInputPhone
|
||||||
|
v-model="main.phonePrimary"
|
||||||
|
:label="t('commercial.clients.form.main.phonePrimary')"
|
||||||
|
:mask="PHONE_MASK"
|
||||||
|
:required="true"
|
||||||
|
:readonly="mainLocked"
|
||||||
|
:addable="!main.hasSecondaryPhone && !mainLocked"
|
||||||
|
:add-button-label="t('commercial.clients.form.main.addPhone')"
|
||||||
|
@add="main.hasSecondaryPhone = true"
|
||||||
|
/>
|
||||||
|
<MalioInputPhone
|
||||||
|
v-if="main.hasSecondaryPhone"
|
||||||
|
v-model="main.phoneSecondary"
|
||||||
|
:label="t('commercial.clients.form.main.phoneSecondary')"
|
||||||
|
:mask="PHONE_MASK"
|
||||||
|
:readonly="mainLocked"
|
||||||
|
/>
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
:model-value="main.categoryIris"
|
||||||
|
:options="referentials.categories.value"
|
||||||
|
:label="t('commercial.clients.form.main.categories')"
|
||||||
|
:display-tag="true"
|
||||||
|
:disabled="mainLocked"
|
||||||
|
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="main.relationType"
|
||||||
|
:options="relationOptions"
|
||||||
|
:label="t('commercial.clients.form.main.relation')"
|
||||||
|
:disabled="mainLocked"
|
||||||
|
@update:model-value="onRelationChange"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
v-if="main.relationType === 'distributeur'"
|
||||||
|
:model-value="main.distributorIri"
|
||||||
|
:options="referentials.distributors.value"
|
||||||
|
:label="t('commercial.clients.form.main.distributorName')"
|
||||||
|
:disabled="mainLocked"
|
||||||
|
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
v-if="main.relationType === 'courtier'"
|
||||||
|
:model-value="main.brokerIri"
|
||||||
|
:options="referentials.brokers.value"
|
||||||
|
:label="t('commercial.clients.form.main.brokerName')"
|
||||||
|
:disabled="mainLocked"
|
||||||
|
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex items-center gap-6">
|
||||||
|
<MalioCheckbox
|
||||||
|
v-model="main.triageService"
|
||||||
|
:label="t('commercial.clients.form.main.triageService')"
|
||||||
|
:readonly="mainLocked"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
v-if="!mainLocked"
|
||||||
|
class="ml-auto"
|
||||||
|
variant="primary"
|
||||||
|
:label="t('commercial.clients.form.submit')"
|
||||||
|
:disabled="!isMainValid || mainSubmitting"
|
||||||
|
@click="submitMain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Onglets a validation incrementale ─────────────────────────────-->
|
||||||
|
<MalioTabList v-model="activeTab" :tabs="tabs">
|
||||||
|
<!-- Onglet Information -->
|
||||||
|
<template #information>
|
||||||
|
<div class="rounded-md border border-neutral-200 bg-white p-6">
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<MalioInputTextArea
|
||||||
|
v-model="information.description"
|
||||||
|
:label="t('commercial.clients.form.information.description')"
|
||||||
|
:disabled="isValidated('information')"
|
||||||
|
group-class="md:col-span-2"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="information.competitors"
|
||||||
|
:label="t('commercial.clients.form.information.competitors')"
|
||||||
|
:readonly="isValidated('information')"
|
||||||
|
/>
|
||||||
|
<MalioDate
|
||||||
|
v-model="information.foundedAt"
|
||||||
|
:label="t('commercial.clients.form.information.foundedAt')"
|
||||||
|
:readonly="isValidated('information')"
|
||||||
|
/>
|
||||||
|
<MalioInputNumber
|
||||||
|
v-model="information.employeesCount"
|
||||||
|
:label="t('commercial.clients.form.information.employeesCount')"
|
||||||
|
min="0"
|
||||||
|
:disabled="isValidated('information')"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="information.directorName"
|
||||||
|
:label="t('commercial.clients.form.information.directorName')"
|
||||||
|
:readonly="isValidated('information')"
|
||||||
|
/>
|
||||||
|
<MalioInputAmount
|
||||||
|
v-model="information.revenueAmount"
|
||||||
|
:label="t('commercial.clients.form.information.revenueAmount')"
|
||||||
|
:disabled="isValidated('information')"
|
||||||
|
/>
|
||||||
|
<MalioInputAmount
|
||||||
|
v-model="information.profitAmount"
|
||||||
|
:label="t('commercial.clients.form.information.profitAmount')"
|
||||||
|
:disabled="isValidated('information')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="!isValidated('information')" class="mt-4 flex justify-end">
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('commercial.clients.form.submit')"
|
||||||
|
:disabled="tabSubmitting"
|
||||||
|
@click="submitInformation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Onglet Contact -->
|
||||||
|
<template #contact>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<ClientContactBlock
|
||||||
|
v-for="(contact, index) in contacts"
|
||||||
|
:key="index"
|
||||||
|
:model-value="contact"
|
||||||
|
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||||
|
:removable="index > 0"
|
||||||
|
:readonly="isValidated('contact')"
|
||||||
|
@update:model-value="(v) => contacts[index] = v"
|
||||||
|
@remove="askRemoveContact(index)"
|
||||||
|
/>
|
||||||
|
<div v-if="!isValidated('contact')" class="flex items-center justify-between">
|
||||||
|
<MalioButton
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="mdi:plus"
|
||||||
|
icon-position="left"
|
||||||
|
:label="t('commercial.clients.form.contact.add')"
|
||||||
|
:disabled="!canAddContact"
|
||||||
|
@click="addContact"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('commercial.clients.form.submit')"
|
||||||
|
:disabled="!canValidateContacts || tabSubmitting"
|
||||||
|
@click="submitContacts"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Onglet Adresse -->
|
||||||
|
<template #address>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<ClientAddressBlock
|
||||||
|
v-for="(address, index) in addresses"
|
||||||
|
:key="index"
|
||||||
|
:model-value="address"
|
||||||
|
:title="t('commercial.clients.form.address.title', { n: index + 1 })"
|
||||||
|
:category-options="addressCategoryOptions"
|
||||||
|
:site-options="referentials.sites.value"
|
||||||
|
:contact-options="contactOptions"
|
||||||
|
:country-options="countryOptions"
|
||||||
|
:removable="index > 0"
|
||||||
|
:readonly="isValidated('address')"
|
||||||
|
@update:model-value="(v) => addresses[index] = v"
|
||||||
|
@remove="askRemoveAddress(index)"
|
||||||
|
@degraded="onAddressDegraded"
|
||||||
|
/>
|
||||||
|
<div v-if="!isValidated('address')" class="flex items-center justify-between">
|
||||||
|
<MalioButton
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="mdi:plus"
|
||||||
|
icon-position="left"
|
||||||
|
:label="t('commercial.clients.form.address.add')"
|
||||||
|
@click="addAddress"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('commercial.clients.form.submit')"
|
||||||
|
:disabled="!canValidateAddresses || tabSubmitting"
|
||||||
|
@click="submitAddresses"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Onglet Comptabilite (present uniquement si accounting.view) -->
|
||||||
|
<template v-if="canAccountingView" #accounting>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="rounded-md border border-neutral-200 bg-white p-6">
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="accounting.siren"
|
||||||
|
:label="t('commercial.clients.form.accounting.siren')"
|
||||||
|
:mask="SIREN_MASK"
|
||||||
|
:readonly="accountingReadonly"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="accounting.accountNumber"
|
||||||
|
:label="t('commercial.clients.form.accounting.accountNumber')"
|
||||||
|
:readonly="accountingReadonly"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="accounting.tvaModeIri"
|
||||||
|
:options="referentials.tvaModes.value"
|
||||||
|
:label="t('commercial.clients.form.accounting.tvaMode')"
|
||||||
|
:disabled="accountingReadonly"
|
||||||
|
empty-option-label=""
|
||||||
|
@update:model-value="(v: string | number | null) => accounting.tvaModeIri = v === null ? null : String(v)"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="accounting.nTva"
|
||||||
|
:label="t('commercial.clients.form.accounting.nTva')"
|
||||||
|
:readonly="accountingReadonly"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
:model-value="accounting.paymentDelayIri"
|
||||||
|
:options="referentials.paymentDelays.value"
|
||||||
|
:label="t('commercial.clients.form.accounting.paymentDelay')"
|
||||||
|
:disabled="accountingReadonly"
|
||||||
|
empty-option-label=""
|
||||||
|
@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('commercial.clients.form.accounting.paymentType')"
|
||||||
|
:disabled="accountingReadonly"
|
||||||
|
empty-option-label=""
|
||||||
|
@update:model-value="onPaymentTypeChange"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
v-if="isBankRequired"
|
||||||
|
:model-value="accounting.bankIri"
|
||||||
|
:options="referentials.banks.value"
|
||||||
|
:label="t('commercial.clients.form.accounting.bank')"
|
||||||
|
:disabled="accountingReadonly"
|
||||||
|
empty-option-label=""
|
||||||
|
@update:model-value="(v: string | number | null) => accounting.bankIri = v === null ? null : String(v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Blocs RIB (0..n) — obligatoires si type de reglement = LCR. -->
|
||||||
|
<div
|
||||||
|
v-for="(rib, index) in ribs"
|
||||||
|
:key="index"
|
||||||
|
class="rounded-md border border-neutral-200 bg-white p-6"
|
||||||
|
>
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-bold">{{ t('commercial.clients.form.accounting.ribTitle', { n: index + 1 }) }}</h3>
|
||||||
|
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
||||||
|
<MalioButtonIcon
|
||||||
|
v-if="!accountingReadonly"
|
||||||
|
icon="mdi:trash-can-outline"
|
||||||
|
variant="ghost"
|
||||||
|
v-bind="{ ariaLabel: t('commercial.clients.form.accounting.removeRib') }"
|
||||||
|
@click="askRemoveRib(index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="rib.label"
|
||||||
|
:label="t('commercial.clients.form.accounting.ribLabel')"
|
||||||
|
:readonly="accountingReadonly"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="rib.bic"
|
||||||
|
:label="t('commercial.clients.form.accounting.ribBic')"
|
||||||
|
:readonly="accountingReadonly"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="rib.iban"
|
||||||
|
:label="t('commercial.clients.form.accounting.ribIban')"
|
||||||
|
:readonly="accountingReadonly"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!accountingReadonly" class="flex items-center justify-between">
|
||||||
|
<MalioButton
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="mdi:plus"
|
||||||
|
icon-position="left"
|
||||||
|
:label="t('commercial.clients.form.accounting.addRib')"
|
||||||
|
@click="addRib"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('commercial.clients.form.submit')"
|
||||||
|
:disabled="!canValidateAccounting || tabSubmitting"
|
||||||
|
@click="submitAccounting"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Onglets non encore implementes : frame vide, passage automatique. -->
|
||||||
|
<template #transport><TabPlaceholderBlank /></template>
|
||||||
|
<template #statistics><TabPlaceholderBlank /></template>
|
||||||
|
<template #reports><TabPlaceholderBlank /></template>
|
||||||
|
<template #exchanges><TabPlaceholderBlank /></template>
|
||||||
|
</MalioTabList>
|
||||||
|
|
||||||
|
<!-- 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('commercial.clients.form.confirmDelete.title') }}</h2>
|
||||||
|
</template>
|
||||||
|
<p>{{ confirmModal.message }}</p>
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
variant="secondary"
|
||||||
|
button-class="flex-1"
|
||||||
|
:label="t('commercial.clients.form.confirmDelete.cancel')"
|
||||||
|
@click="confirmModal.open = false"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="danger"
|
||||||
|
button-class="flex-1"
|
||||||
|
:label="t('commercial.clients.form.confirmDelete.confirm')"
|
||||||
|
@click="runConfirm"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MalioModal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { useClientReferentials, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
|
||||||
|
import {
|
||||||
|
buildClientFormTabKeys,
|
||||||
|
CLIENT_FORM_PLACEHOLDER_TABS,
|
||||||
|
hasAtLeastOneValidContact,
|
||||||
|
isBankRequiredForPaymentType,
|
||||||
|
isBillingEmailRequired,
|
||||||
|
isContactNamed,
|
||||||
|
isRibRequiredForPaymentType,
|
||||||
|
} from '~/modules/commercial/utils/clientFormRules'
|
||||||
|
import {
|
||||||
|
emptyAddress,
|
||||||
|
emptyContact,
|
||||||
|
emptyRib,
|
||||||
|
type AddressFormDraft,
|
||||||
|
type ContactFormDraft,
|
||||||
|
type RibFormDraft,
|
||||||
|
} from '~/modules/commercial/types/clientForm'
|
||||||
|
import { formatPhoneFR } from '~/shared/utils/phone'
|
||||||
|
|
||||||
|
// Masques de saisie (la normalisation finale reste serveur).
|
||||||
|
const PHONE_MASK = '## ## ## ## ##'
|
||||||
|
const SIREN_MASK = '#########'
|
||||||
|
|
||||||
|
// Codes de categorie interdits sur une adresse (RG-1.29, ERP-78).
|
||||||
|
const FORBIDDEN_ADDRESS_CATEGORY_CODES = ['DISTRIBUTEUR', 'COURTIER']
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const api = useApi()
|
||||||
|
const toast = useToast()
|
||||||
|
const { can } = usePermissions()
|
||||||
|
|
||||||
|
useHead({ title: t('commercial.clients.form.title') })
|
||||||
|
|
||||||
|
// Gating de la route : la creation est reservee a `manage`. Compta (accounting
|
||||||
|
// seul) et Usine sont rediriges vers le repertoire (cf. §0 du ticket).
|
||||||
|
if (!can('commercial.clients.manage')) {
|
||||||
|
await navigateTo('/clients')
|
||||||
|
}
|
||||||
|
|
||||||
|
const canAccountingView = computed(() => can('commercial.clients.accounting.view'))
|
||||||
|
const canAccountingManage = computed(() => can('commercial.clients.accounting.manage'))
|
||||||
|
|
||||||
|
const referentials = useClientReferentials()
|
||||||
|
|
||||||
|
// ── Etat du client cree ────────────────────────────────────────────────────
|
||||||
|
const clientId = ref<number | null>(null)
|
||||||
|
const mainLocked = ref(false)
|
||||||
|
const mainSubmitting = ref(false)
|
||||||
|
const tabSubmitting = ref(false)
|
||||||
|
|
||||||
|
// ── Formulaire principal ────────────────────────────────────────────────────
|
||||||
|
const main = reactive({
|
||||||
|
companyName: null as string | null,
|
||||||
|
firstName: null as string | null,
|
||||||
|
lastName: null as string | null,
|
||||||
|
email: null as string | null,
|
||||||
|
phonePrimary: null as string | null,
|
||||||
|
phoneSecondary: null as string | null,
|
||||||
|
hasSecondaryPhone: false,
|
||||||
|
categoryIris: [] as string[],
|
||||||
|
relationType: 'aucun' as 'aucun' | 'distributeur' | 'courtier',
|
||||||
|
distributorIri: null as string | null,
|
||||||
|
brokerIri: null as string | null,
|
||||||
|
triageService: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const relationOptions = computed<RefOption[]>(() => [
|
||||||
|
{ value: 'aucun', label: t('commercial.clients.form.main.relationNone') },
|
||||||
|
{ value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') },
|
||||||
|
{ value: 'courtier', label: t('commercial.clients.form.main.relationBroker') },
|
||||||
|
])
|
||||||
|
|
||||||
|
// RG-1.01 : firstName OU lastName, + companyName / email / phonePrimary requis.
|
||||||
|
const isMainValid = computed(() => {
|
||||||
|
const filled = (v: string | null) => v !== null && v.trim() !== ''
|
||||||
|
return filled(main.companyName)
|
||||||
|
&& filled(main.email)
|
||||||
|
&& filled(main.phonePrimary)
|
||||||
|
&& (filled(main.firstName) || filled(main.lastName))
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onRelationChange(value: string | number | null): Promise<void> {
|
||||||
|
const relation = String(value ?? 'aucun') as typeof main.relationType
|
||||||
|
main.relationType = relation
|
||||||
|
// Reinitialise la FK non concernee (une seule remplie a la fois, RG-1.03).
|
||||||
|
if (relation !== 'distributeur') main.distributorIri = null
|
||||||
|
if (relation !== 'courtier') main.brokerIri = null
|
||||||
|
|
||||||
|
if (relation === 'distributeur') await referentials.loadDistributors()
|
||||||
|
if (relation === 'courtier') await referentials.loadBrokers()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /clients (groupe client:write:main). Au succes : verrouille + bascule Information. */
|
||||||
|
async function submitMain(): Promise<void> {
|
||||||
|
if (!isMainValid.value || mainSubmitting.value) return
|
||||||
|
mainSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
companyName: main.companyName,
|
||||||
|
firstName: main.firstName || null,
|
||||||
|
lastName: main.lastName || null,
|
||||||
|
email: main.email,
|
||||||
|
phonePrimary: main.phonePrimary,
|
||||||
|
phoneSecondary: main.hasSecondaryPhone ? (main.phoneSecondary || null) : null,
|
||||||
|
categories: main.categoryIris,
|
||||||
|
distributor: main.relationType === 'distributeur' ? main.distributorIri : null,
|
||||||
|
broker: main.relationType === 'courtier' ? main.brokerIri : null,
|
||||||
|
triageService: main.triageService,
|
||||||
|
}
|
||||||
|
const created = await api.post<ClientResponse>('/clients', payload, {
|
||||||
|
headers: { Accept: 'application/ld+json' },
|
||||||
|
toast: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
clientId.value = created.id
|
||||||
|
// Reaffiche les valeurs normalisees renvoyees par le serveur.
|
||||||
|
main.companyName = created.companyName ?? main.companyName
|
||||||
|
main.firstName = created.firstName ?? null
|
||||||
|
main.lastName = created.lastName ?? null
|
||||||
|
main.email = created.email ?? main.email
|
||||||
|
main.phonePrimary = formatPhoneFR(created.phonePrimary) || main.phonePrimary
|
||||||
|
main.phoneSecondary = formatPhoneFR(created.phoneSecondary) || null
|
||||||
|
|
||||||
|
// Pre-remplit le 1er contact a partir du formulaire principal (editable).
|
||||||
|
prefillFirstContact()
|
||||||
|
|
||||||
|
mainLocked.value = true
|
||||||
|
unlockedIndex.value = 0
|
||||||
|
activeTab.value = 'information'
|
||||||
|
toast.success({ title: t('commercial.clients.toast.createSuccess') })
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
// 409 = doublon nom de societe (RG d'unicite) → message explicite.
|
||||||
|
const status = (error as { response?: { status?: number } })?.response?.status
|
||||||
|
toast.error({
|
||||||
|
title: t('commercial.clients.toast.error'),
|
||||||
|
message: status === 409 ? t('commercial.clients.form.duplicateCompany') : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
mainSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Onglets : ordre + gating progressif ─────────────────────────────────────
|
||||||
|
const activeTab = ref('information')
|
||||||
|
// Index du dernier onglet deverrouille (-1 tant que le client n'est pas cree).
|
||||||
|
const unlockedIndex = ref(-1)
|
||||||
|
// Onglets valides (passent en lecture seule).
|
||||||
|
const validated = reactive<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
const tabKeys = computed(() => buildClientFormTabKeys(canAccountingView.value))
|
||||||
|
|
||||||
|
const tabs = computed(() => tabKeys.value.map((key, index) => ({
|
||||||
|
key,
|
||||||
|
label: t(`commercial.clients.tab.${key}`),
|
||||||
|
disabled: index > unlockedIndex.value,
|
||||||
|
})))
|
||||||
|
|
||||||
|
function isValidated(key: string): boolean {
|
||||||
|
return validated[key] === true
|
||||||
|
}
|
||||||
|
|
||||||
|
function tabIndex(key: string): number {
|
||||||
|
return tabKeys.value.indexOf(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marque l'onglet valide, deverrouille et avance automatiquement au suivant. */
|
||||||
|
function completeTab(key: string): void {
|
||||||
|
validated[key] = true
|
||||||
|
const next = tabKeys.value[tabIndex(key) + 1]
|
||||||
|
unlockedIndex.value = Math.max(unlockedIndex.value, tabIndex(key) + 1)
|
||||||
|
if (next) activeTab.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passage automatique sur les onglets coquille (Transport, Stats, Rapports, Echanges).
|
||||||
|
watch(activeTab, (key) => {
|
||||||
|
if ((CLIENT_FORM_PLACEHOLDER_TABS as readonly string[]).includes(key)) {
|
||||||
|
const next = tabKeys.value[tabIndex(key) + 1]
|
||||||
|
unlockedIndex.value = Math.max(unlockedIndex.value, tabIndex(key) + 1)
|
||||||
|
if (next) activeTab.value = next
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Onglet Information ──────────────────────────────────────────────────────
|
||||||
|
const information = reactive({
|
||||||
|
description: null as string | null,
|
||||||
|
competitors: null as string | null,
|
||||||
|
foundedAt: null as string | null,
|
||||||
|
employeesCount: null as string | null,
|
||||||
|
revenueAmount: null as string | null,
|
||||||
|
profitAmount: null as string | null,
|
||||||
|
directorName: null as string | null,
|
||||||
|
})
|
||||||
|
|
||||||
|
/** PATCH /clients/{id} — mode strict : uniquement les champs du groupe information. */
|
||||||
|
async function submitInformation(): Promise<void> {
|
||||||
|
if (clientId.value === null || tabSubmitting.value) return
|
||||||
|
tabSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await api.patch(`/clients/${clientId.value}`, {
|
||||||
|
description: information.description || null,
|
||||||
|
competitors: information.competitors || null,
|
||||||
|
foundedAt: information.foundedAt || null,
|
||||||
|
employeesCount: information.employeesCount ? Number(information.employeesCount) : null,
|
||||||
|
revenueAmount: information.revenueAmount || null,
|
||||||
|
profitAmount: information.profitAmount || null,
|
||||||
|
directorName: information.directorName || null,
|
||||||
|
}, { toast: false })
|
||||||
|
completeTab('information')
|
||||||
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
toast.error({ title: t('commercial.clients.toast.error') })
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
tabSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Onglet Contact ──────────────────────────────────────────────────────────
|
||||||
|
const contacts = ref<ContactFormDraft[]>([emptyContact()])
|
||||||
|
|
||||||
|
/** Pre-remplit le 1er contact depuis le formulaire principal (apres creation). */
|
||||||
|
function prefillFirstContact(): void {
|
||||||
|
const first = contacts.value[0]
|
||||||
|
if (!first) return
|
||||||
|
first.lastName = main.lastName
|
||||||
|
first.firstName = main.firstName
|
||||||
|
first.email = main.email
|
||||||
|
first.phonePrimary = main.phonePrimary
|
||||||
|
}
|
||||||
|
|
||||||
|
// « + Nouveau contact » desactive tant que le dernier bloc n'a ni nom ni prenom.
|
||||||
|
const canAddContact = computed(() => {
|
||||||
|
const last = contacts.value[contacts.value.length - 1]
|
||||||
|
return last !== undefined && isContactNamed(last)
|
||||||
|
})
|
||||||
|
|
||||||
|
// RG-1.14 : au moins un contact nomme pour finaliser l'onglet.
|
||||||
|
const canValidateContacts = computed(() => hasAtLeastOneValidContact(contacts.value))
|
||||||
|
|
||||||
|
function addContact(): void {
|
||||||
|
if (canAddContact.value) contacts.value.push(emptyContact())
|
||||||
|
}
|
||||||
|
|
||||||
|
function askRemoveContact(index: number): void {
|
||||||
|
askConfirm(t('commercial.clients.form.confirmDelete.contact'), () => {
|
||||||
|
contacts.value.splice(index, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST/PATCH des contacts sur la sous-ressource /clients/{id}/contacts. */
|
||||||
|
async function submitContacts(): Promise<void> {
|
||||||
|
if (clientId.value === null || !canValidateContacts.value || tabSubmitting.value) return
|
||||||
|
tabSubmitting.value = true
|
||||||
|
try {
|
||||||
|
for (const contact of contacts.value) {
|
||||||
|
// On ignore les blocs totalement vides (ni nom ni prenom).
|
||||||
|
if (!isContactNamed(contact)) continue
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
firstName: contact.firstName || null,
|
||||||
|
lastName: contact.lastName || null,
|
||||||
|
jobTitle: contact.jobTitle || null,
|
||||||
|
phonePrimary: contact.phonePrimary || null,
|
||||||
|
phoneSecondary: contact.hasSecondaryPhone ? (contact.phoneSecondary || null) : null,
|
||||||
|
email: contact.email || null,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contact.id === null) {
|
||||||
|
const created = await api.post<ContactResponse>(
|
||||||
|
`/clients/${clientId.value}/contacts`,
|
||||||
|
body,
|
||||||
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
|
)
|
||||||
|
contact.id = created.id
|
||||||
|
contact.iri = created['@id'] ?? null
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await api.patch(`/client_contacts/${contact.id}`, body, { toast: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
completeTab('contact')
|
||||||
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
toast.error({ title: t('commercial.clients.toast.error') })
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
tabSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Onglet Adresse ──────────────────────────────────────────────────────────
|
||||||
|
const addresses = ref<AddressFormDraft[]>([emptyAddress()])
|
||||||
|
const addressDegradedNotified = ref(false)
|
||||||
|
|
||||||
|
// Categories autorisees sur une adresse : toutes SAUF DISTRIBUTEUR/COURTIER (RG-1.29).
|
||||||
|
const addressCategoryOptions = computed(() =>
|
||||||
|
referentials.categories.value.filter(c => !FORBIDDEN_ADDRESS_CATEGORY_CODES.includes(c.code)),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Contacts deja crees, rattachables a une adresse (M2M, via leur IRI).
|
||||||
|
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 ?? ''),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
// France par defaut au M1 (liste pays minimale ; a etendre quand le besoin viendra).
|
||||||
|
const countryOptions: RefOption[] = [{ value: 'France', label: 'France' }]
|
||||||
|
|
||||||
|
// RG-1.10 (>= 1 site) + RG-1.11 (email facturation si Facturation) sur chaque adresse.
|
||||||
|
const canValidateAddresses = computed(() =>
|
||||||
|
addresses.value.length > 0
|
||||||
|
&& addresses.value.every((a) => {
|
||||||
|
const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== ''
|
||||||
|
return a.siteIris.length >= 1 && (!isBillingEmailRequired(a) || filledBillingEmail)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
function addAddress(): void {
|
||||||
|
addresses.value.push(emptyAddress())
|
||||||
|
}
|
||||||
|
|
||||||
|
function askRemoveAddress(index: number): void {
|
||||||
|
askConfirm(t('commercial.clients.form.confirmDelete.address'), () => {
|
||||||
|
addresses.value.splice(index, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Avertit une seule fois quand l'autocompletion d'adresse bascule en degrade. */
|
||||||
|
function onAddressDegraded(): void {
|
||||||
|
if (addressDegradedNotified.value) return
|
||||||
|
addressDegradedNotified.value = true
|
||||||
|
toast.warning({
|
||||||
|
title: t('commercial.clients.toast.error'),
|
||||||
|
message: t('commercial.clients.form.address.degraded'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST des adresses sur la sous-ressource /clients/{id}/addresses. */
|
||||||
|
async function submitAddresses(): Promise<void> {
|
||||||
|
if (clientId.value === null || !canValidateAddresses.value || tabSubmitting.value) return
|
||||||
|
tabSubmitting.value = true
|
||||||
|
try {
|
||||||
|
for (const address of addresses.value) {
|
||||||
|
const body = {
|
||||||
|
isProspect: address.isProspect,
|
||||||
|
isDelivery: address.isDelivery,
|
||||||
|
isBilling: address.isBilling,
|
||||||
|
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,
|
||||||
|
billingEmail: isBillingEmailRequired(address) ? (address.billingEmail || null) : null,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (address.id === null) {
|
||||||
|
const created = await api.post<{ id: number }>(
|
||||||
|
`/clients/${clientId.value}/addresses`,
|
||||||
|
body,
|
||||||
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
|
)
|
||||||
|
address.id = created.id
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await api.patch(`/client_addresses/${address.id}`, body, { toast: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
completeTab('address')
|
||||||
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
toast.error({ title: t('commercial.clients.toast.error') })
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
tabSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Onglet Comptabilite ─────────────────────────────────────────────────────
|
||||||
|
const accounting = reactive({
|
||||||
|
siren: null as string | null,
|
||||||
|
accountNumber: null as string | null,
|
||||||
|
tvaModeIri: null as string | null,
|
||||||
|
nTva: null as string | null,
|
||||||
|
paymentDelayIri: null as string | null,
|
||||||
|
paymentTypeIri: null as string | null,
|
||||||
|
bankIri: null as string | null,
|
||||||
|
})
|
||||||
|
const ribs = ref<RibFormDraft[]>([])
|
||||||
|
|
||||||
|
// L'onglet est editable seulement avec accounting.manage (sinon lecture seule).
|
||||||
|
const accountingReadonly = computed(() => isValidated('accounting') || !canAccountingManage.value)
|
||||||
|
|
||||||
|
// Code du type de reglement selectionne (pour RG-1.12 / RG-1.13).
|
||||||
|
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))
|
||||||
|
|
||||||
|
function onPaymentTypeChange(value: string | number | null): void {
|
||||||
|
accounting.paymentTypeIri = value === null ? null : String(value)
|
||||||
|
// La banque n'a de sens que pour un virement : on la vide sinon (RG-1.12).
|
||||||
|
if (!isBankRequired.value) accounting.bankIri = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function ribIsComplete(rib: RibFormDraft): boolean {
|
||||||
|
const filled = (v: string | null) => v !== null && v.trim() !== ''
|
||||||
|
return filled(rib.label) && filled(rib.bic) && filled(rib.iban)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RG-1.12 : banque requise si VIREMENT. RG-1.13 : >= 1 RIB complet si LCR.
|
||||||
|
const canValidateAccounting = computed(() => {
|
||||||
|
if (isBankRequired.value && (accounting.bankIri === null)) return false
|
||||||
|
if (isRibRequired.value && !ribs.value.some(ribIsComplete)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
function addRib(): void {
|
||||||
|
ribs.value.push(emptyRib())
|
||||||
|
}
|
||||||
|
|
||||||
|
function askRemoveRib(index: number): void {
|
||||||
|
askConfirm(t('commercial.clients.form.confirmDelete.rib'), () => {
|
||||||
|
ribs.value.splice(index, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valide l'onglet Comptabilite : PATCH des scalaires (groupe client:write:accounting)
|
||||||
|
* PUIS POST des RIB sur /clients/{id}/ribs. Deux appels distincts (mode strict
|
||||||
|
* RG-1.28 : il n'existe pas d'endpoint /accounting, cf. recon back).
|
||||||
|
*/
|
||||||
|
async function submitAccounting(): Promise<void> {
|
||||||
|
if (clientId.value === null || !canValidateAccounting.value || tabSubmitting.value) return
|
||||||
|
tabSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await api.patch(`/clients/${clientId.value}`, {
|
||||||
|
siren: accounting.siren || null,
|
||||||
|
accountNumber: accounting.accountNumber || null,
|
||||||
|
tvaMode: accounting.tvaModeIri,
|
||||||
|
nTva: accounting.nTva || null,
|
||||||
|
paymentDelay: accounting.paymentDelayIri,
|
||||||
|
paymentType: accounting.paymentTypeIri,
|
||||||
|
bank: isBankRequired.value ? accounting.bankIri : null,
|
||||||
|
}, { toast: false })
|
||||||
|
|
||||||
|
for (const rib of ribs.value) {
|
||||||
|
if (!ribIsComplete(rib)) continue
|
||||||
|
if (rib.id === null) {
|
||||||
|
const created = await api.post<{ id: number }>(
|
||||||
|
`/clients/${clientId.value}/ribs`,
|
||||||
|
{ label: rib.label, bic: rib.bic, iban: rib.iban },
|
||||||
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
|
)
|
||||||
|
rib.id = created.id
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await api.patch(`/client_ribs/${rib.id}`, { label: rib.label, bic: rib.bic, iban: rib.iban }, { toast: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
completeTab('accounting')
|
||||||
|
toast.success({ title: t('commercial.clients.toast.updateSuccess') })
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
toast.error({ title: t('commercial.clients.toast.error') })
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
tabSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Types de reponse API ────────────────────────────────────────────────────
|
||||||
|
interface ClientResponse {
|
||||||
|
id: number
|
||||||
|
companyName: string | null
|
||||||
|
firstName: string | null
|
||||||
|
lastName: string | null
|
||||||
|
email: string | null
|
||||||
|
phonePrimary: string | null
|
||||||
|
phoneSecondary: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContactResponse {
|
||||||
|
'@id'?: string
|
||||||
|
id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Echec du chargement des referentiels non bloquant : les selects restent vides.
|
||||||
|
referentials.loadCommon().catch(() => {})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Types « brouillon » de l'ecran « Ajouter un client » (M1 Commercial).
|
||||||
|
*
|
||||||
|
* Ces interfaces decrivent l'etat LOCAL du formulaire (refs Vue), distinct des
|
||||||
|
* DTO de l'API : elles portent en plus des champs purement UI (`hasSecondaryPhone`)
|
||||||
|
* et l'`iri` Hydra des entites creees (necessaire pour rattacher une adresse a
|
||||||
|
* des contacts deja persistes, M2M). Partage par la page et les blocs reutilisables
|
||||||
|
* `ClientContactBlock` / `ClientAddressBlock` (reutilises par 1.11/1.12).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Un contact du client (onglet Contact). */
|
||||||
|
export interface ContactFormDraft {
|
||||||
|
/** Id serveur une fois le contact cree (null tant que non persiste). */
|
||||||
|
id: number | null
|
||||||
|
/** IRI Hydra du contact cree — utilise pour le rattachement M2M cote adresse. */
|
||||||
|
iri: string | null
|
||||||
|
firstName: string | null
|
||||||
|
lastName: string | null
|
||||||
|
jobTitle: string | null
|
||||||
|
phonePrimary: string | null
|
||||||
|
phoneSecondary: string | null
|
||||||
|
email: string | null
|
||||||
|
/** UI : le 2e numero a ete revele via le bouton « + ». */
|
||||||
|
hasSecondaryPhone: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Une adresse du client (onglet Adresse). */
|
||||||
|
export interface AddressFormDraft {
|
||||||
|
id: number | null
|
||||||
|
isProspect: boolean
|
||||||
|
isDelivery: boolean
|
||||||
|
isBilling: boolean
|
||||||
|
country: string
|
||||||
|
postalCode: string | null
|
||||||
|
city: string | null
|
||||||
|
street: string | null
|
||||||
|
streetComplement: string | null
|
||||||
|
/** IRI des categories rattachees (hors DISTRIBUTEUR/COURTIER — RG-1.29). */
|
||||||
|
categoryIris: string[]
|
||||||
|
/** IRI des sites Starseed rattaches (>= 1 obligatoire — RG-1.10). */
|
||||||
|
siteIris: string[]
|
||||||
|
/** IRI des contacts rattaches (= blocs Contact deja crees). */
|
||||||
|
contactIris: string[]
|
||||||
|
/** Email de facturation (obligatoire si isBilling — RG-1.11). */
|
||||||
|
billingEmail: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Un RIB du client (onglet Comptabilite). */
|
||||||
|
export interface RibFormDraft {
|
||||||
|
id: number | null
|
||||||
|
label: string | null
|
||||||
|
bic: string | null
|
||||||
|
iban: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fabrique un contact vierge. */
|
||||||
|
export function emptyContact(): ContactFormDraft {
|
||||||
|
return {
|
||||||
|
id: null,
|
||||||
|
iri: null,
|
||||||
|
firstName: null,
|
||||||
|
lastName: null,
|
||||||
|
jobTitle: null,
|
||||||
|
phonePrimary: null,
|
||||||
|
phoneSecondary: null,
|
||||||
|
email: null,
|
||||||
|
hasSecondaryPhone: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fabrique une adresse vierge (pays prerempli « France »). */
|
||||||
|
export function emptyAddress(): AddressFormDraft {
|
||||||
|
return {
|
||||||
|
id: null,
|
||||||
|
isProspect: false,
|
||||||
|
isDelivery: false,
|
||||||
|
isBilling: false,
|
||||||
|
country: 'France',
|
||||||
|
postalCode: null,
|
||||||
|
city: null,
|
||||||
|
street: null,
|
||||||
|
streetComplement: null,
|
||||||
|
categoryIris: [],
|
||||||
|
siteIris: [],
|
||||||
|
contactIris: [],
|
||||||
|
billingEmail: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fabrique un RIB vierge. */
|
||||||
|
export function emptyRib(): RibFormDraft {
|
||||||
|
return {
|
||||||
|
id: null,
|
||||||
|
label: null,
|
||||||
|
bic: null,
|
||||||
|
iban: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
applyProspectExclusivity,
|
||||||
|
buildClientFormTabKeys,
|
||||||
|
canSelectDeliveryOrBilling,
|
||||||
|
canSelectProspect,
|
||||||
|
hasAtLeastOneValidContact,
|
||||||
|
isBankRequiredForPaymentType,
|
||||||
|
isBillingEmailRequired,
|
||||||
|
isContactNamed,
|
||||||
|
isRibRequiredForPaymentType,
|
||||||
|
type ContactDraft,
|
||||||
|
} from '../clientFormRules'
|
||||||
|
|
||||||
|
describe('buildClientFormTabKeys (gating onglet Comptabilite)', () => {
|
||||||
|
it('inclut l onglet accounting si l utilisateur a accounting.view', () => {
|
||||||
|
expect(buildClientFormTabKeys(true)).toContain('accounting')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exclut l onglet accounting sinon (Bureau / Commerciale)', () => {
|
||||||
|
expect(buildClientFormTabKeys(false)).not.toContain('accounting')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('place accounting entre transport et statistics quand present', () => {
|
||||||
|
const keys = buildClientFormTabKeys(true)
|
||||||
|
expect(keys).toEqual([
|
||||||
|
'information',
|
||||||
|
'contact',
|
||||||
|
'address',
|
||||||
|
'transport',
|
||||||
|
'accounting',
|
||||||
|
'statistics',
|
||||||
|
'reports',
|
||||||
|
'exchanges',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isContactNamed (RG-1.05)', () => {
|
||||||
|
it('vrai si le prenom est renseigne', () => {
|
||||||
|
expect(isContactNamed({ firstName: 'Alice', lastName: null })).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vrai si le nom est renseigne', () => {
|
||||||
|
expect(isContactNamed({ firstName: null, lastName: 'Martin' })).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('faux si les deux sont vides ou espaces uniquement', () => {
|
||||||
|
expect(isContactNamed({ firstName: null, lastName: null })).toBe(false)
|
||||||
|
expect(isContactNamed({ firstName: ' ', lastName: '' })).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('hasAtLeastOneValidContact (RG-1.14)', () => {
|
||||||
|
it('faux sur une liste vide', () => {
|
||||||
|
expect(hasAtLeastOneValidContact([])).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('faux si aucun contact n a de nom ni prenom', () => {
|
||||||
|
const contacts: ContactDraft[] = [
|
||||||
|
{ firstName: null, lastName: null },
|
||||||
|
{ firstName: '', lastName: ' ' },
|
||||||
|
]
|
||||||
|
expect(hasAtLeastOneValidContact(contacts)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('vrai des qu un contact a un nom ou un prenom', () => {
|
||||||
|
const contacts: ContactDraft[] = [
|
||||||
|
{ firstName: null, lastName: null },
|
||||||
|
{ firstName: 'Bob', lastName: null },
|
||||||
|
]
|
||||||
|
expect(hasAtLeastOneValidContact(contacts)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('exclusivite Prospect / Livraison / Facturation (RG-1.06/07/08)', () => {
|
||||||
|
it('Prospect est selectionnable tant que ni Livraison ni Facturation', () => {
|
||||||
|
expect(canSelectProspect({ isProspect: false, isDelivery: false, isBilling: false })).toBe(true)
|
||||||
|
expect(canSelectProspect({ isProspect: false, isDelivery: true, isBilling: false })).toBe(false)
|
||||||
|
expect(canSelectProspect({ isProspect: false, isDelivery: false, isBilling: true })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Livraison / Facturation selectionnables tant que pas Prospect', () => {
|
||||||
|
expect(canSelectDeliveryOrBilling({ isProspect: false, isDelivery: false, isBilling: false })).toBe(true)
|
||||||
|
expect(canSelectDeliveryOrBilling({ isProspect: true, isDelivery: false, isBilling: false })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cocher Prospect efface Livraison et Facturation', () => {
|
||||||
|
const next = applyProspectExclusivity(
|
||||||
|
{ isProspect: false, isDelivery: true, isBilling: true },
|
||||||
|
'isProspect',
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
expect(next).toEqual({ isProspect: true, isDelivery: false, isBilling: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cocher Livraison efface Prospect', () => {
|
||||||
|
const next = applyProspectExclusivity(
|
||||||
|
{ isProspect: true, isDelivery: false, isBilling: false },
|
||||||
|
'isDelivery',
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cocher Facturation efface Prospect mais conserve Livraison', () => {
|
||||||
|
const next = applyProspectExclusivity(
|
||||||
|
{ isProspect: true, isDelivery: true, isBilling: false },
|
||||||
|
'isBilling',
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('decocher un drapeau ne reactive rien d autre', () => {
|
||||||
|
const next = applyProspectExclusivity(
|
||||||
|
{ isProspect: false, isDelivery: true, isBilling: true },
|
||||||
|
'isBilling',
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: false })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isBillingEmailRequired (RG-1.11)', () => {
|
||||||
|
it('obligatoire uniquement si Facturation est coche', () => {
|
||||||
|
expect(isBillingEmailRequired({ isProspect: false, isDelivery: false, isBilling: true })).toBe(true)
|
||||||
|
expect(isBillingEmailRequired({ isProspect: false, isDelivery: true, isBilling: false })).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('regles type de reglement (RG-1.12 / RG-1.13)', () => {
|
||||||
|
it('banque obligatoire si VIREMENT', () => {
|
||||||
|
expect(isBankRequiredForPaymentType('VIREMENT')).toBe(true)
|
||||||
|
expect(isBankRequiredForPaymentType('LCR')).toBe(false)
|
||||||
|
expect(isBankRequiredForPaymentType(null)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('RIB obligatoire si LCR', () => {
|
||||||
|
expect(isRibRequiredForPaymentType('LCR')).toBe(true)
|
||||||
|
expect(isRibRequiredForPaymentType('VIREMENT')).toBe(false)
|
||||||
|
expect(isRibRequiredForPaymentType(null)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* Regles metier pures de l'ecran « Ajouter un client » (M1 Commercial).
|
||||||
|
*
|
||||||
|
* Centralisees ici (hors composant) pour rester testables unitairement et
|
||||||
|
* partagees entre la page de creation et les futurs ecrans d'edition (1.11/1.12).
|
||||||
|
* Ces helpers ne touchent ni a l'API ni a l'etat reactif : ils prennent des
|
||||||
|
* brouillons « plats » et retournent des booleens / nouveaux objets.
|
||||||
|
*
|
||||||
|
* Le back reste la source de verite (les RG sont re-validees serveur) ; ces
|
||||||
|
* regles ne servent qu'au feedback UI immediat (gating de boutons, visibilite).
|
||||||
|
*
|
||||||
|
* NOTE RG-1.04 (Information obligatoire pour la Commerciale) : volontairement
|
||||||
|
* NON miroite cote front pour l'instant. Le payload /api/me ne porte pas le code
|
||||||
|
* de role (roles = IRIs opaques) et Bureau partage les memes permissions que
|
||||||
|
* Commerciale : aucun signal fiable pour distinguer le role cote front. Le back
|
||||||
|
* (ClientProcessor, via BusinessRoleAware) applique la regle de maniere fiable ;
|
||||||
|
* a rebrancher ici des qu'un code de role sera expose dans /api/me.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Onglets « coquille » (non encore implementes) : frame vide, passage
|
||||||
|
* automatique a l'onglet suivant (decision Tristan 28/05).
|
||||||
|
*/
|
||||||
|
export const CLIENT_FORM_PLACEHOLDER_TABS = ['transport', 'statistics', 'reports', 'exchanges'] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit l'ordre des onglets de l'ecran « Ajouter un client ». L'onglet
|
||||||
|
* Comptabilite n'est present que si l'utilisateur a `accounting.view` — sinon il
|
||||||
|
* est totalement absent (Bureau / Commerciale ne le voient pas). Ordre aligne
|
||||||
|
* sur la spec M1 § Ecran « Ajouter un client ».
|
||||||
|
*/
|
||||||
|
export function buildClientFormTabKeys(canAccountingView: boolean): string[] {
|
||||||
|
const keys = ['information', 'contact', 'address', 'transport']
|
||||||
|
if (canAccountingView) {
|
||||||
|
keys.push('accounting')
|
||||||
|
}
|
||||||
|
keys.push('statistics', 'reports', 'exchanges')
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sous-ensemble d'un contact necessaire aux regles de nommage (RG-1.05/1.14). */
|
||||||
|
export interface ContactDraft {
|
||||||
|
firstName: string | null
|
||||||
|
lastName: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drapeaux d'usage d'une adresse (RG-1.06/07/08/11). */
|
||||||
|
export interface AddressFlagsDraft {
|
||||||
|
isProspect: boolean
|
||||||
|
isDelivery: boolean
|
||||||
|
isBilling: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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-1.05 : un contact est valide des qu'il porte un nom OU un prenom.
|
||||||
|
*/
|
||||||
|
export function isContactNamed(contact: ContactDraft): boolean {
|
||||||
|
return isFilled(contact.firstName) || isFilled(contact.lastName)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RG-1.14 : l'onglet Contact ne peut etre finalise que s'il reste au moins un
|
||||||
|
* contact nomme (nom ou prenom).
|
||||||
|
*/
|
||||||
|
export function hasAtLeastOneValidContact(contacts: ContactDraft[]): boolean {
|
||||||
|
return contacts.some(isContactNamed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RG-1.06/07/08 : une adresse de prospection est exclusive d'une adresse de
|
||||||
|
* livraison/facturation. Prospect n'est selectionnable que si ni Livraison ni
|
||||||
|
* Facturation ne sont coches.
|
||||||
|
*/
|
||||||
|
export function canSelectProspect(flags: AddressFlagsDraft): boolean {
|
||||||
|
return !flags.isDelivery && !flags.isBilling
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RG-1.06/07/08 : Livraison et Facturation ne sont selectionnables que si
|
||||||
|
* Prospect n'est pas coche.
|
||||||
|
*/
|
||||||
|
export function canSelectDeliveryOrBilling(flags: AddressFlagsDraft): boolean {
|
||||||
|
return !flags.isProspect
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applique l'exclusivite Prospect / (Livraison|Facturation) au changement d'un
|
||||||
|
* drapeau. Cocher Prospect efface Livraison + Facturation ; cocher Livraison ou
|
||||||
|
* Facturation efface Prospect. Decocher n'a aucun effet de bord. Retourne un
|
||||||
|
* nouvel objet (pas de mutation de l'entree).
|
||||||
|
*/
|
||||||
|
export function applyProspectExclusivity(
|
||||||
|
flags: AddressFlagsDraft,
|
||||||
|
field: keyof AddressFlagsDraft,
|
||||||
|
value: boolean,
|
||||||
|
): AddressFlagsDraft {
|
||||||
|
const next: AddressFlagsDraft = { ...flags, [field]: value }
|
||||||
|
|
||||||
|
if (value && field === 'isProspect') {
|
||||||
|
next.isDelivery = false
|
||||||
|
next.isBilling = false
|
||||||
|
}
|
||||||
|
else if (value && (field === 'isDelivery' || field === 'isBilling')) {
|
||||||
|
next.isProspect = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RG-1.11 : l'email de facturation n'est visible/obligatoire que si l'adresse
|
||||||
|
* est une adresse de facturation.
|
||||||
|
*/
|
||||||
|
export function isBillingEmailRequired(flags: AddressFlagsDraft): boolean {
|
||||||
|
return flags.isBilling
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Code stable du type de reglement « virement » (cf. PaymentType.code, RG-1.12). */
|
||||||
|
const PAYMENT_TYPE_TRANSFER = 'VIREMENT'
|
||||||
|
|
||||||
|
/** Code stable du type de reglement « lettre de change » (RG-1.13). */
|
||||||
|
const PAYMENT_TYPE_LCR = 'LCR'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RG-1.12 : la banque est obligatoire lorsque le type de reglement est un
|
||||||
|
* virement.
|
||||||
|
*/
|
||||||
|
export function isBankRequiredForPaymentType(code: string | null | undefined): boolean {
|
||||||
|
return code === PAYMENT_TYPE_TRANSFER
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RG-1.13 : au moins un RIB complet est obligatoire lorsque le type de reglement
|
||||||
|
* est une LCR.
|
||||||
|
*/
|
||||||
|
export function isRibRequiredForPaymentType(code: string | null | undefined): boolean {
|
||||||
|
return code === PAYMENT_TYPE_LCR
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// STUB ERP-63 — remplacé par l'implémentation BAN d'ERP-66.
|
||||||
|
//
|
||||||
|
// Ce fichier appartient fonctionnellement à ERP-66 (#66). ERP-63 n'en livre
|
||||||
|
// qu'un STUB pour ne pas se bloquer : la vraie implémentation (appels
|
||||||
|
// api-adresse.data.gouv.fr) viendra remplacer le CORPS des deux méthodes SANS
|
||||||
|
// changer leur signature ni l'usage côté composant.
|
||||||
|
//
|
||||||
|
// Contrat figé par ERP-66 (c'est lui qui fait foi) :
|
||||||
|
// searchCity(postalCode) -> liste { city, postalCode }
|
||||||
|
// searchAddress(query, cp?) -> liste { label, street, postalCode, city }
|
||||||
|
// En cas d'erreur/timeout, la méthode THROW. Le composant catch l'erreur,
|
||||||
|
// affiche un toast d'avertissement et bascule en saisie libre (MalioInputText).
|
||||||
|
//
|
||||||
|
// Comportement du stub : les deux méthodes throw systématiquement → l'onglet
|
||||||
|
// Adresse part directement en mode dégradé (Ville + Adresse en saisie libre,
|
||||||
|
// Code postal saisi manuellement). Aucun appel réseau n'est émis ici.
|
||||||
|
|
||||||
|
/** Une suggestion de ville renvoyée à partir d'un code postal. */
|
||||||
|
export interface CitySuggestion {
|
||||||
|
city: string
|
||||||
|
postalCode: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Une suggestion d'adresse complète (saisie assistée du champ « Adresse »). */
|
||||||
|
export interface AddressSuggestion {
|
||||||
|
label: string
|
||||||
|
street: string
|
||||||
|
postalCode: string
|
||||||
|
city: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddressAutocomplete {
|
||||||
|
searchCity(postalCode: string): Promise<CitySuggestion[]>
|
||||||
|
searchAddress(query: string, postalCode?: string): Promise<AddressSuggestion[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erreur signalant que le service d'autocomplétion BAN n'est pas disponible. */
|
||||||
|
export class AddressAutocompleteUnavailableError extends Error {
|
||||||
|
constructor() {
|
||||||
|
// Message technique (non affiché tel quel) : le composant remonte son
|
||||||
|
// propre libellé i18n. Sert au debug / aux logs uniquement.
|
||||||
|
super('Address autocomplete (BAN) is not available yet — ERP-66 stub.')
|
||||||
|
this.name = 'AddressAutocompleteUnavailableError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STUB : renvoie un composable conforme au contrat ERP-66 dont les méthodes
|
||||||
|
* échouent toujours, forçant le mode dégradé côté onglet Adresse.
|
||||||
|
*/
|
||||||
|
export function useAddressAutocomplete(): AddressAutocomplete {
|
||||||
|
return {
|
||||||
|
async searchCity(_postalCode: string): Promise<CitySuggestion[]> {
|
||||||
|
throw new AddressAutocompleteUnavailableError()
|
||||||
|
},
|
||||||
|
async searchAddress(_query: string, _postalCode?: string): Promise<AddressSuggestion[]> {
|
||||||
|
throw new AddressAutocompleteUnavailableError()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { formatPhoneFR } from '../phone'
|
||||||
|
|
||||||
|
describe('formatPhoneFR', () => {
|
||||||
|
it('formate un numero 10 chiffres en XX XX XX XX XX', () => {
|
||||||
|
expect(formatPhoneFR('0612345678')).toBe('06 12 34 56 78')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tolere une saisie deja pointee ou espacee', () => {
|
||||||
|
expect(formatPhoneFR('06.12.34.56.78')).toBe('06 12 34 56 78')
|
||||||
|
expect(formatPhoneFR('06 12 34 56 78')).toBe('06 12 34 56 78')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retourne une chaine vide pour une valeur vide ou nulle', () => {
|
||||||
|
expect(formatPhoneFR('')).toBe('')
|
||||||
|
expect(formatPhoneFR(null)).toBe('')
|
||||||
|
expect(formatPhoneFR(undefined)).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('groupe par 2 meme un nombre impair de chiffres (dernier groupe seul)', () => {
|
||||||
|
expect(formatPhoneFR('123')).toBe('12 3')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Formatage d'un numero de telephone francais en groupes de 2 chiffres
|
||||||
|
* (`XX XX XX XX XX`).
|
||||||
|
*
|
||||||
|
* Helper PARTAGE volontaire : les telephones sont presents un peu partout dans
|
||||||
|
* l'app (fiches clients, contacts, fournisseurs, prestataires...). Introduit ici
|
||||||
|
* comme util transverse stable plutot que duplique a chaque ecran. La signature
|
||||||
|
* `formatPhoneFR(value): string` est coordonnee avec ERP-66, qui pourra enrichir
|
||||||
|
* l'implementation (validation, indicatif international) sans casser les appelants.
|
||||||
|
*
|
||||||
|
* - Ne garde que les chiffres puis groupe par 2 (tolere une saisie deja espacee
|
||||||
|
* ou pointee, ex: `06.12.34.56.78` ou `0612345678`).
|
||||||
|
* - Retourne une chaine vide si la valeur est vide/nulle (cellule vide propre).
|
||||||
|
*/
|
||||||
|
export function formatPhoneFR(value: string | null | undefined): string {
|
||||||
|
const digits = (value ?? '').replace(/\D/g, '')
|
||||||
|
if (digits.length === 0) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Groupe par paquets de 2 ; un dernier groupe impair reste tel quel.
|
||||||
|
return digits.match(/.{1,2}/g)?.join(' ') ?? digits
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ use App\Module\Commercial\Infrastructure\Doctrine\DoctrineClientRepository;
|
|||||||
use App\Shared\Domain\Attribute\Auditable;
|
use App\Shared\Domain\Attribute\Auditable;
|
||||||
use App\Shared\Domain\Contract\BlamableInterface;
|
use App\Shared\Domain\Contract\BlamableInterface;
|
||||||
use App\Shared\Domain\Contract\CategoryInterface;
|
use App\Shared\Domain\Contract\CategoryInterface;
|
||||||
|
use App\Shared\Domain\Contract\SiteInterface;
|
||||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
@@ -58,27 +59,39 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
operations: [
|
operations: [
|
||||||
new GetCollection(
|
new GetCollection(
|
||||||
security: "is_granted('commercial.clients.view')",
|
security: "is_granted('commercial.clients.view')",
|
||||||
normalizationContext: ['groups' => ['client:read', 'default:read']],
|
// La liste embarque les categories (avec leur code, groupe
|
||||||
|
// category:read) et les sites agreges des adresses (groupe
|
||||||
|
// site:read) pour alimenter les colonnes « Catégories » et
|
||||||
|
// « Site(s) » du Repertoire (ERP-62). Cf. getSites() plus bas.
|
||||||
|
normalizationContext: ['groups' => ['client:read', 'default:read', 'category:read', 'site:read']],
|
||||||
provider: ClientProvider::class,
|
provider: ClientProvider::class,
|
||||||
),
|
),
|
||||||
new Get(
|
new Get(
|
||||||
security: "is_granted('commercial.clients.view')",
|
security: "is_granted('commercial.clients.view')",
|
||||||
// Detail : client + sous-collections embarquees. Le groupe
|
// Detail : client + sous-collections embarquees.
|
||||||
// client:read:accounting est ajoute par le context builder selon la
|
// - client:read:accounting est ajoute par le context builder selon la
|
||||||
// permission, donc absent ici volontairement.
|
// permission (gate les scalaires comptables ET les RIB embarques),
|
||||||
|
// donc absent ici volontairement.
|
||||||
|
// - client_rib:read N'EST PLUS dans le contexte : le contenu des RIB
|
||||||
|
// embarques est desormais porte par client:read:accounting (gate),
|
||||||
|
// ce qui retire la fuite IBAN/BIC vers les users sans accounting.view.
|
||||||
|
// - category:read et site:read sont indispensables pour embarquer le
|
||||||
|
// code/libelle des categories et des sites (sinon stub IRI nu) :
|
||||||
|
// Category.code/name vivent sous category:read, Site.name sous site:read.
|
||||||
normalizationContext: ['groups' => [
|
normalizationContext: ['groups' => [
|
||||||
'client:read',
|
'client:read',
|
||||||
'client:item:read',
|
'client:item:read',
|
||||||
'client_contact:read',
|
'client_contact:read',
|
||||||
'client_address:read',
|
'client_address:read',
|
||||||
'client_rib:read',
|
'category:read',
|
||||||
|
'site:read',
|
||||||
'default:read',
|
'default:read',
|
||||||
]],
|
]],
|
||||||
provider: ClientProvider::class,
|
provider: ClientProvider::class,
|
||||||
),
|
),
|
||||||
new Post(
|
new Post(
|
||||||
security: "is_granted('commercial.clients.manage')",
|
security: "is_granted('commercial.clients.manage')",
|
||||||
normalizationContext: ['groups' => ['client:read', 'default:read']],
|
normalizationContext: ['groups' => ['client:read', 'default:read', 'category:read', 'site:read']],
|
||||||
denormalizationContext: ['groups' => ['client:write:main']],
|
denormalizationContext: ['groups' => ['client:write:main']],
|
||||||
processor: ClientProcessor::class,
|
processor: ClientProcessor::class,
|
||||||
),
|
),
|
||||||
@@ -96,7 +109,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
// autoriser/refuser onglet par onglet (RG-1.22 / RG-1.28) : les
|
// autoriser/refuser onglet par onglet (RG-1.22 / RG-1.28) : les
|
||||||
// champs accounting exigent accounting.manage, isArchived exige
|
// champs accounting exigent accounting.manage, isArchived exige
|
||||||
// archive, le reste (main/information) exige manage.
|
// archive, le reste (main/information) exige manage.
|
||||||
normalizationContext: ['groups' => ['client:read', 'default:read']],
|
normalizationContext: ['groups' => ['client:read', 'default:read', 'category:read', 'site:read']],
|
||||||
denormalizationContext: ['groups' => [
|
denormalizationContext: ['groups' => [
|
||||||
'client:write:main',
|
'client:write:main',
|
||||||
'client:write:information',
|
'client:write:information',
|
||||||
@@ -651,8 +664,38 @@ class Client implements TimestampableInterface, BlamableInterface
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sites distincts rattaches a au moins une adresse du client (RG-1.10).
|
||||||
|
* Le Client ne porte pas de sites en propre : ils vivent sur les adresses.
|
||||||
|
* Agrege en lecture seule pour la colonne « Site(s) » du Repertoire (badges
|
||||||
|
* colores) — expose en LISTE via le groupe client:read (les adresses
|
||||||
|
* completes restent reservees au detail, client:item:read).
|
||||||
|
*
|
||||||
|
* @return list<SiteInterface>
|
||||||
|
*/
|
||||||
|
#[Groups(['client:read'])]
|
||||||
|
public function getSites(): array
|
||||||
|
{
|
||||||
|
$sites = [];
|
||||||
|
foreach ($this->addresses as $address) {
|
||||||
|
foreach ($address->getSites() as $site) {
|
||||||
|
// Deduplication par identite d'objet : un meme site peut etre
|
||||||
|
// rattache a plusieurs adresses du client.
|
||||||
|
$sites[spl_object_id($site)] = $site;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values($sites);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Embed gate sur le groupe COMPTABLE (et non client:item:read comme contacts/
|
||||||
|
// adresses) : client:read:accounting n'est ajoute au contexte que si l'user a
|
||||||
|
// accounting.view (ClientReadGroupContextBuilder). Resultat : la cle `ribs` est
|
||||||
|
// TOTALEMENT ABSENTE du detail pour un user sans accounting.view (ex. Commerciale),
|
||||||
|
// au meme titre que les scalaires comptables — corrige la fuite de RIB ou la
|
||||||
|
// Commerciale recevait IBAN/BIC en clair.
|
||||||
/** @return Collection<int, ClientRib> */
|
/** @return Collection<int, ClientRib> */
|
||||||
#[Groups(['client:item:read'])]
|
#[Groups(['client:read:accounting'])]
|
||||||
public function getRibs(): Collection
|
public function getRibs(): Collection
|
||||||
{
|
{
|
||||||
return $this->ribs;
|
return $this->ribs;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ use Doctrine\Common\Collections\ArrayCollection;
|
|||||||
use Doctrine\Common\Collections\Collection;
|
use Doctrine\Common\Collections\Collection;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||||
|
|
||||||
@@ -104,16 +105,23 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
|
|||||||
#[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
#[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||||
private ?Client $client = null;
|
private ?Client $client = null;
|
||||||
|
|
||||||
|
// Groupe d'ECRITURE uniquement sur la propriete (denormalisation PATCH/POST).
|
||||||
|
// Le groupe de LECTURE est porte par le getter isProspect()/isDelivery()/
|
||||||
|
// isBilling() avec SerializedName : sans cela, Symfony strip le prefixe "is"
|
||||||
|
// des getters booleens et exposerait les cles "prospect"/"delivery"/"billing"
|
||||||
|
// — en pratique le #[Groups] etant sur la propriete `isX` et le getter
|
||||||
|
// derivant l'attribut `x`, la cle etait totalement DROPPEE du JSON (meme bug
|
||||||
|
// que Client::isArchived). Pattern corrige : Groups + SerializedName sur le getter.
|
||||||
#[ORM\Column(name: 'is_prospect', options: ['default' => false])]
|
#[ORM\Column(name: 'is_prospect', options: ['default' => false])]
|
||||||
#[Groups(['client_address:read', 'client_address:write'])]
|
#[Groups(['client_address:write'])]
|
||||||
private bool $isProspect = false;
|
private bool $isProspect = false;
|
||||||
|
|
||||||
#[ORM\Column(name: 'is_delivery', options: ['default' => false])]
|
#[ORM\Column(name: 'is_delivery', options: ['default' => false])]
|
||||||
#[Groups(['client_address:read', 'client_address:write'])]
|
#[Groups(['client_address:write'])]
|
||||||
private bool $isDelivery = false;
|
private bool $isDelivery = false;
|
||||||
|
|
||||||
#[ORM\Column(name: 'is_billing', options: ['default' => false])]
|
#[ORM\Column(name: 'is_billing', options: ['default' => false])]
|
||||||
#[Groups(['client_address:read', 'client_address:write'])]
|
#[Groups(['client_address:write'])]
|
||||||
private bool $isBilling = false;
|
private bool $isBilling = false;
|
||||||
|
|
||||||
#[ORM\Column(length: 80, options: ['default' => 'France'])]
|
#[ORM\Column(length: 80, options: ['default' => 'France'])]
|
||||||
@@ -276,6 +284,12 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Groupe de lecture + nom serialise explicite (cf. note sur la propriete) :
|
||||||
|
// sans SerializedName, Symfony exposerait la cle "prospect" (strip du prefixe
|
||||||
|
// "is" sur les getters) et, le groupe etant declare sur la propriete `isProspect`,
|
||||||
|
// droppait silencieusement la cle du JSON.
|
||||||
|
#[Groups(['client_address:read'])]
|
||||||
|
#[SerializedName('isProspect')]
|
||||||
public function isProspect(): bool
|
public function isProspect(): bool
|
||||||
{
|
{
|
||||||
return $this->isProspect;
|
return $this->isProspect;
|
||||||
@@ -288,6 +302,8 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Groups(['client_address:read'])]
|
||||||
|
#[SerializedName('isDelivery')]
|
||||||
public function isDelivery(): bool
|
public function isDelivery(): bool
|
||||||
{
|
{
|
||||||
return $this->isDelivery;
|
return $this->isDelivery;
|
||||||
@@ -300,6 +316,8 @@ class ClientAddress implements TimestampableInterface, BlamableInterface
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Groups(['client_address:read'])]
|
||||||
|
#[SerializedName('isBilling')]
|
||||||
public function isBilling(): bool
|
public function isBilling(): bool
|
||||||
{
|
{
|
||||||
return $this->isBilling;
|
return $this->isBilling;
|
||||||
|
|||||||
@@ -79,10 +79,17 @@ class ClientRib implements TimestampableInterface, BlamableInterface
|
|||||||
{
|
{
|
||||||
use TimestampableBlamableTrait;
|
use TimestampableBlamableTrait;
|
||||||
|
|
||||||
|
// Double groupe de lecture :
|
||||||
|
// - `client_rib:read` : sous-ressource autonome GET /api/client_ribs/{id}
|
||||||
|
// (deja securisee par commercial.clients.accounting.view).
|
||||||
|
// - `client:read:accounting` : embed des RIB sous le detail Client, ajoute
|
||||||
|
// DYNAMIQUEMENT par ClientReadGroupContextBuilder uniquement si l'user a
|
||||||
|
// accounting.view. Ce double marquage gate les RIB embarques au meme titre
|
||||||
|
// que les scalaires comptables (RG : la Commerciale ne voit aucun RIB).
|
||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['client_rib:read'])]
|
#[Groups(['client_rib:read', 'client:read:accounting'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: Client::class, inversedBy: 'ribs')]
|
#[ORM\ManyToOne(targetEntity: Client::class, inversedBy: 'ribs')]
|
||||||
@@ -92,23 +99,23 @@ class ClientRib implements TimestampableInterface, BlamableInterface
|
|||||||
#[ORM\Column(length: 120)]
|
#[ORM\Column(length: 120)]
|
||||||
#[Assert\NotBlank]
|
#[Assert\NotBlank]
|
||||||
#[Assert\Length(max: 120, normalizer: 'trim')]
|
#[Assert\Length(max: 120, normalizer: 'trim')]
|
||||||
#[Groups(['client_rib:read', 'client_rib:write'])]
|
#[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])]
|
||||||
private ?string $label = null;
|
private ?string $label = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 20)]
|
#[ORM\Column(length: 20)]
|
||||||
#[Assert\NotBlank]
|
#[Assert\NotBlank]
|
||||||
#[Assert\Bic]
|
#[Assert\Bic]
|
||||||
#[Groups(['client_rib:read', 'client_rib:write'])]
|
#[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])]
|
||||||
private ?string $bic = null;
|
private ?string $bic = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 34)]
|
#[ORM\Column(length: 34)]
|
||||||
#[Assert\NotBlank]
|
#[Assert\NotBlank]
|
||||||
#[Assert\Iban]
|
#[Assert\Iban]
|
||||||
#[Groups(['client_rib:read', 'client_rib:write'])]
|
#[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])]
|
||||||
private ?string $iban = null;
|
private ?string $iban = null;
|
||||||
|
|
||||||
#[ORM\Column(options: ['default' => 0])]
|
#[ORM\Column(options: ['default' => 0])]
|
||||||
#[Groups(['client_rib:read', 'client_rib:write'])]
|
#[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])]
|
||||||
private int $position = 0;
|
private int $position = 0;
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
|
|||||||
@@ -16,21 +16,31 @@ interface ClientRepositoryInterface
|
|||||||
/**
|
/**
|
||||||
* Construit un QueryBuilder de liste pour le repertoire clients.
|
* Construit un QueryBuilder de liste pour le repertoire clients.
|
||||||
* - Exclut toujours les clients soft-deletes (deleted_at IS NOT NULL, RG-1.24).
|
* - Exclut toujours les clients soft-deletes (deleted_at IS NOT NULL, RG-1.24).
|
||||||
* - Exclut les archives sauf si $includeArchived = true (RG-1.25).
|
* - Archivage (RG-1.25) :
|
||||||
|
* - $archivedOnly = true -> uniquement les archives (is_archived = true) ;
|
||||||
|
* - sinon $includeArchived = true -> actifs + archives (echappatoire) ;
|
||||||
|
* - sinon (defaut) -> uniquement les actifs (is_archived = false).
|
||||||
|
* $archivedOnly a la priorite sur $includeArchived.
|
||||||
* - Tri par defaut : companyName ASC (RG-1.26).
|
* - Tri par defaut : companyName ASC (RG-1.26).
|
||||||
* - $search : recherche fuzzy insensible a la casse sur companyName +
|
* - $search : recherche fuzzy insensible a la casse sur companyName +
|
||||||
* lastName + email (metacaracteres LIKE echappes). Ignore si null/vide.
|
* lastName + email (metacaracteres LIKE echappes). Ignore si null/vide.
|
||||||
* - $categoryCode : restreint aux clients possedant au moins une categorie
|
* - $categoryCodes : restreint aux clients possedant au moins une categorie
|
||||||
* du code donne (ERP-78 : filtrage par code de Category, plus par type).
|
* dont le code est dans la liste (OR — ERP-78). Liste vide = pas de filtre.
|
||||||
* Ignore si null/vide.
|
* - $siteIds : restreint aux clients ayant au moins une adresse rattachee a
|
||||||
|
* l'un des sites donnes (OR — RG-1.10). Liste vide = pas de filtre.
|
||||||
*
|
*
|
||||||
* Filtrage centralise ICI (et non dans les providers/controllers) pour que
|
* Filtrage centralise ICI (et non dans les providers/controllers) pour que
|
||||||
* la liste paginee (ClientProvider) et l'export (ClientExportController)
|
* la liste paginee (ClientProvider) et l'export (ClientExportController)
|
||||||
* partagent strictement la meme logique de selection.
|
* partagent strictement la meme logique de selection.
|
||||||
|
*
|
||||||
|
* @param list<string> $categoryCodes
|
||||||
|
* @param list<int> $siteIds
|
||||||
*/
|
*/
|
||||||
public function createListQueryBuilder(
|
public function createListQueryBuilder(
|
||||||
bool $includeArchived = false,
|
bool $includeArchived = false,
|
||||||
?string $search = null,
|
?string $search = null,
|
||||||
?string $categoryCode = null,
|
array $categoryCodes = [],
|
||||||
|
array $siteIds = [],
|
||||||
|
bool $archivedOnly = false,
|
||||||
): QueryBuilder;
|
): QueryBuilder;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,14 +64,20 @@ final class ClientProvider implements ProviderInterface
|
|||||||
{
|
{
|
||||||
$filters = $context['filters'] ?? [];
|
$filters = $context['filters'] ?? [];
|
||||||
$includeArchived = $this->readBool($filters['includeArchived'] ?? false);
|
$includeArchived = $this->readBool($filters['includeArchived'] ?? false);
|
||||||
|
$archivedOnly = $this->readBool($filters['archivedOnly'] ?? false);
|
||||||
$search = $filters['search'] ?? null;
|
$search = $filters['search'] ?? null;
|
||||||
$categoryCode = $filters['categoryCode'] ?? null;
|
// categoryCode accepte un code unique (?categoryCode=DISTRIBUTEUR, selects
|
||||||
|
// RG-1.03) OU une liste (?categoryCode[]=A&categoryCode[]=B, drawer multi).
|
||||||
|
$categoryCodes = $this->readStringList($filters['categoryCode'] ?? []);
|
||||||
|
$siteIds = $this->readIntList($filters['siteId'] ?? []);
|
||||||
|
|
||||||
// Filtrage delegue au repository (logique partagee avec l'export XLSX).
|
// Filtrage delegue au repository (logique partagee avec l'export XLSX).
|
||||||
$qb = $this->repository->createListQueryBuilder(
|
$qb = $this->repository->createListQueryBuilder(
|
||||||
$includeArchived,
|
$includeArchived,
|
||||||
is_string($search) ? $search : null,
|
is_string($search) ? $search : null,
|
||||||
is_string($categoryCode) ? $categoryCode : null,
|
$categoryCodes,
|
||||||
|
$siteIds,
|
||||||
|
$archivedOnly,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Echappatoire ?pagination=false : collection complete sans Paginator
|
// Echappatoire ?pagination=false : collection complete sans Paginator
|
||||||
@@ -127,4 +133,44 @@ final class ClientProvider implements ProviderInterface
|
|||||||
|
|
||||||
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
|
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise un filtre en liste de chaines. Tolere un code unique (string)
|
||||||
|
* ou une liste (?key[]=a&key[]=b). Trim + retrait des vides.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function readStringList(mixed $raw): array
|
||||||
|
{
|
||||||
|
$values = is_array($raw) ? $raw : [$raw];
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($values as $value) {
|
||||||
|
if (is_string($value) && '' !== trim($value)) {
|
||||||
|
$out[] = trim($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise un filtre en liste d'identifiants entiers positifs. Tolere une
|
||||||
|
* valeur unique ou une liste (?key[]=1&key[]=2).
|
||||||
|
*
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
private function readIntList(mixed $raw): array
|
||||||
|
{
|
||||||
|
$values = is_array($raw) ? $raw : [$raw];
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($values as $value) {
|
||||||
|
if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) {
|
||||||
|
$out[] = (int) $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,12 +52,19 @@ final class ClientExportController
|
|||||||
public function __invoke(Request $request): Response
|
public function __invoke(Request $request): Response
|
||||||
{
|
{
|
||||||
$includeArchived = $this->readBool($request->query->get('includeArchived'));
|
$includeArchived = $this->readBool($request->query->get('includeArchived'));
|
||||||
|
$archivedOnly = $this->readBool($request->query->get('archivedOnly'));
|
||||||
$search = $request->query->getString('search') ?: null;
|
$search = $request->query->getString('search') ?: null;
|
||||||
$categoryCode = $request->query->getString('categoryCode') ?: null;
|
|
||||||
|
// Memes filtres que la vue liste : categoryCode/siteId tolerent une valeur
|
||||||
|
// unique ou une liste (?categoryCode[]=A&siteId[]=1). On lit via all() pour
|
||||||
|
// ne pas lever d'exception sur une valeur scalaire.
|
||||||
|
$query = $request->query->all();
|
||||||
|
$categoryCodes = $this->readStringList($query['categoryCode'] ?? []);
|
||||||
|
$siteIds = $this->readIntList($query['siteId'] ?? []);
|
||||||
|
|
||||||
/** @var list<Client> $clients */
|
/** @var list<Client> $clients */
|
||||||
$clients = $this->repository
|
$clients = $this->repository
|
||||||
->createListQueryBuilder($includeArchived, $search, $categoryCode)
|
->createListQueryBuilder($includeArchived, $search, $categoryCodes, $siteIds, $archivedOnly)
|
||||||
->getQuery()
|
->getQuery()
|
||||||
->getResult()
|
->getResult()
|
||||||
;
|
;
|
||||||
@@ -198,4 +205,44 @@ final class ClientExportController
|
|||||||
{
|
{
|
||||||
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
|
return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise un filtre en liste de chaines (valeur unique ou liste).
|
||||||
|
* Aligne sur ClientProvider pour un comportement identique a la liste.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function readStringList(mixed $raw): array
|
||||||
|
{
|
||||||
|
$values = is_array($raw) ? $raw : [$raw];
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($values as $value) {
|
||||||
|
if (is_string($value) && '' !== trim($value)) {
|
||||||
|
$out[] = trim($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise un filtre en liste d'identifiants entiers positifs (valeur unique
|
||||||
|
* ou liste). Aligne sur ClientProvider.
|
||||||
|
*
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
private function readIntList(mixed $raw): array
|
||||||
|
{
|
||||||
|
$values = is_array($raw) ? $raw : [$raw];
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($values as $value) {
|
||||||
|
if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) {
|
||||||
|
$out[] = (int) $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,19 +34,34 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
|
|||||||
public function createListQueryBuilder(
|
public function createListQueryBuilder(
|
||||||
bool $includeArchived = false,
|
bool $includeArchived = false,
|
||||||
?string $search = null,
|
?string $search = null,
|
||||||
?string $categoryCode = null,
|
array $categoryCodes = [],
|
||||||
|
array $siteIds = [],
|
||||||
|
bool $archivedOnly = false,
|
||||||
): QueryBuilder {
|
): QueryBuilder {
|
||||||
$qb = $this->createQueryBuilder('c')
|
$qb = $this->createQueryBuilder('c')
|
||||||
|
// Jointures + addSelect pour hydrater en une seule requete les
|
||||||
|
// collections affichees par le Repertoire (colonnes Catégories /
|
||||||
|
// Site(s)) : sans cela, la serialisation declenche un N+1 (une
|
||||||
|
// requete par client, puis par adresse). Le Paginator ORM
|
||||||
|
// (fetchJoinCollection: true, cf. ClientProvider) gere le COUNT
|
||||||
|
// malgre ces jointures to-many.
|
||||||
|
->leftJoin('c.categories', 'cat')->addSelect('cat')
|
||||||
|
->leftJoin('c.addresses', 'addr')->addSelect('addr')
|
||||||
|
->leftJoin('addr.sites', 'site')->addSelect('site')
|
||||||
->andWhere('c.deletedAt IS NULL')
|
->andWhere('c.deletedAt IS NULL')
|
||||||
->orderBy('c.companyName', 'ASC')
|
->orderBy('c.companyName', 'ASC')
|
||||||
;
|
;
|
||||||
|
|
||||||
if (!$includeArchived) {
|
// Perimetre d'archivage : archivedOnly prioritaire sur includeArchived.
|
||||||
|
if ($archivedOnly) {
|
||||||
|
$qb->andWhere('c.isArchived = true');
|
||||||
|
} elseif (!$includeArchived) {
|
||||||
$qb->andWhere('c.isArchived = false');
|
$qb->andWhere('c.isArchived = false');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->applySearch($qb, $search);
|
$this->applySearch($qb, $search);
|
||||||
$this->applyCategoryCode($qb, $categoryCode);
|
$this->applyCategoryCodes($qb, $categoryCodes);
|
||||||
|
$this->applySiteIds($qb, $siteIds);
|
||||||
|
|
||||||
return $qb;
|
return $qb;
|
||||||
}
|
}
|
||||||
@@ -73,15 +88,18 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restreint aux clients possedant au moins une categorie du code donne
|
* Restreint aux clients possedant au moins une categorie dont le code figure
|
||||||
* (ERP-78 : filtrage par code de Category, plus par type). Alimente notamment
|
* dans la liste (OR — ERP-78). Alimente le filtre « Catégories » du drawer
|
||||||
* les selects « distributeur » (categoryCode=DISTRIBUTEUR) et « courtier »
|
* (multi) ainsi que les selects « distributeur »/« courtier » (un seul code,
|
||||||
* (COURTIER) cote front (RG-1.03). Sous-requete IN (plutot qu'un JOIN sur la
|
* RG-1.03). Sous-requete IN (plutot qu'un JOIN sur la collection M2M) pour ne
|
||||||
* collection M2M) pour ne pas perturber le DISTINCT / ORDER BY principal.
|
* pas perturber le DISTINCT / ORDER BY principal.
|
||||||
|
*
|
||||||
|
* @param list<string> $categoryCodes
|
||||||
*/
|
*/
|
||||||
private function applyCategoryCode(QueryBuilder $qb, ?string $categoryCode): void
|
private function applyCategoryCodes(QueryBuilder $qb, array $categoryCodes): void
|
||||||
{
|
{
|
||||||
if (null === $categoryCode || '' === trim($categoryCode)) {
|
$codes = $this->normalizeStringList($categoryCodes);
|
||||||
|
if ([] === $codes) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,11 +107,84 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
|
|||||||
->select('c2.id')
|
->select('c2.id')
|
||||||
->from(Client::class, 'c2')
|
->from(Client::class, 'c2')
|
||||||
->join('c2.categories', 'cat2')
|
->join('c2.categories', 'cat2')
|
||||||
->where('cat2.code = :categoryCode')
|
->where('cat2.code IN (:categoryCodes)')
|
||||||
;
|
;
|
||||||
|
|
||||||
$qb->andWhere($qb->expr()->in('c.id', $sub->getDQL()))
|
$qb->andWhere($qb->expr()->in('c.id', $sub->getDQL()))
|
||||||
->setParameter('categoryCode', trim($categoryCode))
|
->setParameter('categoryCodes', $codes)
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restreint aux clients ayant au moins une adresse rattachee a l'un des
|
||||||
|
* sites donnes (OR — RG-1.10 : les sites vivent sur les adresses, pas sur le
|
||||||
|
* client). Sous-requete IN pour ne pas perturber le tri/pagination principal.
|
||||||
|
*
|
||||||
|
* @param list<int> $siteIds
|
||||||
|
*/
|
||||||
|
private function applySiteIds(QueryBuilder $qb, array $siteIds): void
|
||||||
|
{
|
||||||
|
$ids = $this->normalizeIntList($siteIds);
|
||||||
|
if ([] === $ids) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sub = $this->getEntityManager()->createQueryBuilder()
|
||||||
|
->select('c3.id')
|
||||||
|
->from(Client::class, 'c3')
|
||||||
|
->join('c3.addresses', 'addr3')
|
||||||
|
->join('addr3.sites', 'site3')
|
||||||
|
->where('site3.id IN (:siteIds)')
|
||||||
|
;
|
||||||
|
|
||||||
|
$qb->andWhere($qb->expr()->in('c.id', $sub->getDQL()))
|
||||||
|
->setParameter('siteIds', $ids)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nettoie une liste de chaines : trim, retrait des vides, reindexation.
|
||||||
|
* Defensive : tolere des elements scalaires non-string (cast) et ignore le
|
||||||
|
* reste sans lever de TypeError, le contrat etant justement de normaliser une
|
||||||
|
* entree potentiellement brute (query params).
|
||||||
|
*
|
||||||
|
* @param array<mixed> $values
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function normalizeStringList(array $values): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
foreach ($values as $value) {
|
||||||
|
if (is_string($value) || is_int($value) || is_float($value)) {
|
||||||
|
$trimmed = trim((string) $value);
|
||||||
|
if ('' !== $trimmed) {
|
||||||
|
$out[] = $trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nettoie une liste d'identifiants : cast int, retrait des <= 0, reindexation.
|
||||||
|
* Defensive (cf. normalizeStringList) : accepte des entiers ou des chaines
|
||||||
|
* numeriques ('1', '2') sans TypeError, ignore le reste.
|
||||||
|
*
|
||||||
|
* @param array<mixed> $values
|
||||||
|
*
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
private function normalizeIntList(array $values): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
foreach ($values as $value) {
|
||||||
|
if (is_numeric($value) && (int) $value > 0) {
|
||||||
|
$out[] = (int) $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Tests\Module\Commercial\Api;
|
namespace App\Tests\Module\Commercial\Api;
|
||||||
|
|
||||||
|
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||||
|
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
|
||||||
|
use App\Module\Commercial\Domain\Entity\ClientAddress;
|
||||||
|
use App\Module\Sites\Domain\Entity\Site;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests fonctionnels de l'API /api/clients (M1) — branche ERP-55.
|
* Tests fonctionnels de l'API /api/clients (M1) — branche ERP-55.
|
||||||
*
|
*
|
||||||
@@ -177,8 +182,8 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
|
|||||||
|
|
||||||
public function testPostBrokerReferencingNonBrokerReturns422(): void
|
public function testPostBrokerReferencingNonBrokerReturns422(): void
|
||||||
{
|
{
|
||||||
$client = $this->createAdminClient();
|
$client = $this->createAdminClient();
|
||||||
$cat = $this->createCategory('SECTEUR');
|
$cat = $this->createCategory('SECTEUR');
|
||||||
$notBroker = $this->seedClient('Pas Un Courtier', false, 'SECTEUR');
|
$notBroker = $this->seedClient('Pas Un Courtier', false, 'SECTEUR');
|
||||||
|
|
||||||
$client->request('POST', '/api/clients', [
|
$client->request('POST', '/api/clients', [
|
||||||
@@ -325,4 +330,146 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
|
|||||||
self::assertArrayHasKey('addresses', $data);
|
self::assertArrayHasKey('addresses', $data);
|
||||||
self::assertArrayHasKey('ribs', $data);
|
self::assertArrayHasKey('ribs', $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ERP-62 : la LISTE doit alimenter les colonnes « Catégories » (codes) et
|
||||||
|
* « Site(s) » (badges name + color) du Repertoire. On verifie donc que la
|
||||||
|
* collection embarque le `code` de chaque categorie et les sites agreges des
|
||||||
|
* adresses (accessoire Client::getSites()).
|
||||||
|
*/
|
||||||
|
public function testListEmbedsCategoryCodesAndAggregatedSites(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
|
||||||
|
// Client seede + une adresse rattachee a un site (fixtures Sites).
|
||||||
|
$seed = $this->seedClient('Embed List Co', false, 'DISTRIBUTEUR');
|
||||||
|
$em = $this->getEm();
|
||||||
|
$site = $em->getRepository(Site::class)->findOneBy([]);
|
||||||
|
self::assertNotNull($site, 'Aucun site seede : impossible de tester la colonne Site(s).');
|
||||||
|
|
||||||
|
$address = new ClientAddress();
|
||||||
|
$address->setClient($seed);
|
||||||
|
$address->setPostalCode('86100');
|
||||||
|
$address->setCity('Châtellerault');
|
||||||
|
$address->setStreet('1 rue du Test');
|
||||||
|
$address->addSite($site);
|
||||||
|
$em->persist($address);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
$member = $client->request('GET', '/api/clients?pagination=false', [
|
||||||
|
'headers' => ['Accept' => self::LD],
|
||||||
|
])->toArray()['member'];
|
||||||
|
|
||||||
|
$row = null;
|
||||||
|
foreach ($member as $candidate) {
|
||||||
|
if ('EMBED LIST CO' === $candidate['companyName']) {
|
||||||
|
$row = $candidate;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self::assertNotNull($row, 'Le client seede doit figurer dans la liste.');
|
||||||
|
|
||||||
|
// Colonne « Catégories » : chaque categorie embarquee porte son code.
|
||||||
|
self::assertNotEmpty($row['categories']);
|
||||||
|
self::assertArrayHasKey('code', $row['categories'][0]);
|
||||||
|
self::assertSame('DISTRIBUTEUR', $row['categories'][0]['code']);
|
||||||
|
|
||||||
|
// Colonne « Site(s) » : sites agreges des adresses, avec name + color.
|
||||||
|
self::assertArrayHasKey('sites', $row);
|
||||||
|
self::assertNotEmpty($row['sites']);
|
||||||
|
self::assertArrayHasKey('name', $row['sites'][0]);
|
||||||
|
self::assertArrayHasKey('color', $row['sites'][0]);
|
||||||
|
self::assertSame($site->getName(), $row['sites'][0]['name']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ERP-62 (drawer) : filtre Catégories multi (?categoryCode[]=A&categoryCode[]=B)
|
||||||
|
* — union des clients possedant l'un OU l'autre code.
|
||||||
|
*/
|
||||||
|
public function testListFilterByMultipleCategoryCodes(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$this->seedClient('Filtre Distrib Co', false, 'DISTRIBUTEUR');
|
||||||
|
$this->seedClient('Filtre Courtier Co', false, 'COURTIER');
|
||||||
|
$this->seedClient('Filtre Secteur Co', false, 'SECTEUR');
|
||||||
|
|
||||||
|
$names = $this->companyNames($client, '/api/clients?pagination=false&categoryCode[]=DISTRIBUTEUR&categoryCode[]=COURTIER');
|
||||||
|
|
||||||
|
self::assertContains('FILTRE DISTRIB CO', $names);
|
||||||
|
self::assertContains('FILTRE COURTIER CO', $names);
|
||||||
|
self::assertNotContains('FILTRE SECTEUR CO', $names);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ERP-62 (drawer) : filtre Sites (?siteId[]=X) — clients ayant >= 1 adresse
|
||||||
|
* rattachee au site donne.
|
||||||
|
*/
|
||||||
|
public function testListFilterBySite(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$em = $this->getEm();
|
||||||
|
|
||||||
|
$sites = $em->getRepository(Site::class)->findBy([], null, 2);
|
||||||
|
self::assertCount(2, $sites, 'Deux sites seedes requis pour ce test.');
|
||||||
|
[$siteA, $siteB] = $sites;
|
||||||
|
|
||||||
|
$onSiteA = $this->seedClient('Client Sur Site A');
|
||||||
|
$this->attachAddressWithSite($onSiteA, $siteA);
|
||||||
|
|
||||||
|
$onSiteB = $this->seedClient('Client Sur Site B');
|
||||||
|
$this->attachAddressWithSite($onSiteB, $siteB);
|
||||||
|
|
||||||
|
$names = $this->companyNames($client, '/api/clients?pagination=false&siteId[]='.$siteA->getId());
|
||||||
|
|
||||||
|
self::assertContains('CLIENT SUR SITE A', $names);
|
||||||
|
self::assertNotContains('CLIENT SUR SITE B', $names);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ERP-62 (drawer) : statut « Archivés » (?archivedOnly=true) — uniquement les
|
||||||
|
* archives, contrairement a includeArchived qui ajoute les archives aux actifs.
|
||||||
|
*/
|
||||||
|
public function testListArchivedOnlyReturnsOnlyArchived(): void
|
||||||
|
{
|
||||||
|
$client = $this->createAdminClient();
|
||||||
|
$this->seedClient('Actif Visible Co');
|
||||||
|
$this->seedClient('Archive Visible Co', true);
|
||||||
|
|
||||||
|
$names = $this->companyNames($client, '/api/clients?pagination=false&archivedOnly=true');
|
||||||
|
|
||||||
|
self::assertContains('ARCHIVE VISIBLE CO', $names);
|
||||||
|
self::assertNotContains('ACTIF VISIBLE CO', $names);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rattache une adresse minimale portant un site au client (les sites vivent
|
||||||
|
* sur les adresses, RG-1.10).
|
||||||
|
*/
|
||||||
|
private function attachAddressWithSite(ClientEntity $client, Site $site): void
|
||||||
|
{
|
||||||
|
$em = $this->getEm();
|
||||||
|
$address = new ClientAddress();
|
||||||
|
$address->setClient($client);
|
||||||
|
$address->setPostalCode('86100');
|
||||||
|
$address->setCity('Châtellerault');
|
||||||
|
$address->setStreet('1 rue du Test');
|
||||||
|
$address->addSite($site);
|
||||||
|
$em->persist($address);
|
||||||
|
$em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper : recupere les companyName d'une collection /api/clients.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function companyNames(Client $client, string $url): array
|
||||||
|
{
|
||||||
|
$members = $client->request('GET', $url, [
|
||||||
|
'headers' => ['Accept' => self::LD],
|
||||||
|
])->toArray()['member'];
|
||||||
|
|
||||||
|
return array_map(static fn (array $c): string => $c['companyName'], $members);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Commercial\Api;
|
||||||
|
|
||||||
|
use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
|
||||||
|
use App\Module\Commercial\Domain\Entity\ClientAddress;
|
||||||
|
use App\Module\Commercial\Domain\Entity\ClientContact;
|
||||||
|
use App\Module\Commercial\Domain\Entity\ClientRib;
|
||||||
|
use App\Module\Sites\Domain\Entity\Site;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests anti-regression du CONTRAT DE SERIALISATION du repertoire clients (M1).
|
||||||
|
*
|
||||||
|
* Captures reelles du 02/06/2026 (cf. docs/specs/M2-suppliers/spec-back.md
|
||||||
|
* § 4.0.ter) ayant revele 4 bugs silencieux du contrat (aucune erreur levee) :
|
||||||
|
* - #81 : booleens d'adresse (isProspect/isDelivery/isBilling) absents du JSON
|
||||||
|
* (Groups sur la propriete `isX`, getter `isX()` derivant l'attribut `x`).
|
||||||
|
* - #80 : fuite RIB (IBAN/BIC) vers un user sans accounting.view.
|
||||||
|
* - #82 : code/libelle de Category et Site non embarques (stub IRI nu).
|
||||||
|
* - enveloppe AP4 : member/totalItems/view sans prefixe `hydra:`, archives exclus.
|
||||||
|
*
|
||||||
|
* REGLE D'OR : ces tests assertent sur le CORPS JSON reel, jamais sur les
|
||||||
|
* annotations. Toute regression de groupe de serialisation casse ici.
|
||||||
|
*
|
||||||
|
* Limite connue (dependance module Sites) : l'entite Site ne porte PAS de champ
|
||||||
|
* `code` (ni SiteInterface) — son libelle est `name`. Les « codes 86/17/82 » de
|
||||||
|
* la spec M2 correspondent en realite au prefixe du code postal des 3 sites
|
||||||
|
* fixtures (86100/17400/82400). On asserte donc le libelle `name` du site
|
||||||
|
* embarque ; l'ajout d'un `Site.code` reste un ticket cote module Sites.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class ClientSerializationContractTest extends AbstractCommercialApiTestCase
|
||||||
|
{
|
||||||
|
private const string LD = 'application/ld+json';
|
||||||
|
private const string VALID_IBAN = 'FR1420041010050500013M02606';
|
||||||
|
private const string VALID_BIC = 'BNPAFRPPXXX';
|
||||||
|
|
||||||
|
// === #81 — Booleens d'adresse presents dans le JSON ===
|
||||||
|
|
||||||
|
public function testAddressBooleansArePresentInDetail(): void
|
||||||
|
{
|
||||||
|
$this->skipIfSitesModuleDisabled();
|
||||||
|
|
||||||
|
$seed = $this->seedCompleteClient('Bool Addr Co');
|
||||||
|
$id = $seed->getId();
|
||||||
|
|
||||||
|
$http = $this->createAdminClient();
|
||||||
|
$data = $http->request('GET', '/api/clients/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||||
|
|
||||||
|
self::assertArrayHasKey('addresses', $data);
|
||||||
|
self::assertNotEmpty($data['addresses']);
|
||||||
|
$address = $data['addresses'][0];
|
||||||
|
|
||||||
|
// Le bug droppait TOTALEMENT ces cles. Apres correctif (Groups +
|
||||||
|
// SerializedName sur le getter), elles sont presentes ET typees bool.
|
||||||
|
self::assertArrayHasKey('isProspect', $address);
|
||||||
|
self::assertArrayHasKey('isDelivery', $address);
|
||||||
|
self::assertArrayHasKey('isBilling', $address);
|
||||||
|
|
||||||
|
// L'adresse seedee est livraison + facturation (prospect exclusif, RG-1.06).
|
||||||
|
// Prouve qu'un booleen `true` est bien serialise (le bug masquait meme les true).
|
||||||
|
self::assertFalse($address['isProspect']);
|
||||||
|
self::assertTrue($address['isDelivery']);
|
||||||
|
self::assertTrue($address['isBilling']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === #80 — Gating des RIB par accounting.view ===
|
||||||
|
|
||||||
|
public function testRibsPresentForAdminWithAccountingView(): void
|
||||||
|
{
|
||||||
|
$this->skipIfSitesModuleDisabled();
|
||||||
|
|
||||||
|
$seed = $this->seedCompleteClient('Rib Admin Co');
|
||||||
|
$id = $seed->getId();
|
||||||
|
|
||||||
|
$http = $this->createAdminClient();
|
||||||
|
$data = $http->request('GET', '/api/clients/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||||
|
|
||||||
|
// Admin bypass RBAC -> accounting.view -> RIB embarques (label/bic/iban).
|
||||||
|
self::assertArrayHasKey('ribs', $data);
|
||||||
|
self::assertNotEmpty($data['ribs']);
|
||||||
|
self::assertSame('Compte principal', $data['ribs'][0]['label']);
|
||||||
|
self::assertSame(self::VALID_IBAN, $data['ribs'][0]['iban']);
|
||||||
|
self::assertSame(self::VALID_BIC, $data['ribs'][0]['bic']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRibsAbsentForUserWithoutAccountingView(): void
|
||||||
|
{
|
||||||
|
$this->skipIfSitesModuleDisabled();
|
||||||
|
|
||||||
|
$seed = $this->seedCompleteClient('Rib Commerciale Co');
|
||||||
|
$id = $seed->getId();
|
||||||
|
|
||||||
|
// Commerciale : commercial.clients.view SANS accounting.view.
|
||||||
|
$creds = $this->createUserWithPermission('commercial.clients.view');
|
||||||
|
$http = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||||
|
|
||||||
|
$data = $http->request('GET', '/api/clients/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||||
|
|
||||||
|
// La cle `ribs` est ABSENTE (pas null) : le groupe client:read:accounting
|
||||||
|
// n'est pas ajoute au contexte -> getRibs() jamais serialise. Fin de la
|
||||||
|
// fuite IBAN/BIC.
|
||||||
|
self::assertArrayNotHasKey('ribs', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === #80.bis — Gating par OMISSION des scalaires comptables ===
|
||||||
|
|
||||||
|
public function testAccountingScalarsGatedByOmission(): void
|
||||||
|
{
|
||||||
|
$this->skipIfSitesModuleDisabled();
|
||||||
|
|
||||||
|
$seed = $this->seedCompleteClient('Compta Gating Co');
|
||||||
|
$id = $seed->getId();
|
||||||
|
|
||||||
|
// Admin : scalaires comptables presents.
|
||||||
|
$admin = $this->createAdminClient();
|
||||||
|
$adminData = $admin->request('GET', '/api/clients/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||||
|
self::assertArrayHasKey('siren', $adminData);
|
||||||
|
self::assertSame('123456789', $adminData['siren']);
|
||||||
|
self::assertArrayHasKey('accountNumber', $adminData);
|
||||||
|
|
||||||
|
// Commerciale : scalaires comptables ABSENTS (omission, pas null).
|
||||||
|
$creds = $this->createUserWithPermission('commercial.clients.view');
|
||||||
|
$http = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||||
|
$data = $http->request('GET', '/api/clients/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||||
|
|
||||||
|
self::assertArrayNotHasKey('siren', $data);
|
||||||
|
self::assertArrayNotHasKey('accountNumber', $data);
|
||||||
|
self::assertArrayNotHasKey('nTva', $data);
|
||||||
|
self::assertArrayNotHasKey('ribs', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === #82 — Embed code/libelle des Category et Site ===
|
||||||
|
|
||||||
|
public function testCategoriesEmbedCodeAndLabel(): void
|
||||||
|
{
|
||||||
|
$this->skipIfSitesModuleDisabled();
|
||||||
|
|
||||||
|
$seed = $this->seedCompleteClient('Embed Cat Co');
|
||||||
|
$id = $seed->getId();
|
||||||
|
|
||||||
|
$http = $this->createAdminClient();
|
||||||
|
$data = $http->request('GET', '/api/clients/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||||
|
|
||||||
|
self::assertNotEmpty($data['categories']);
|
||||||
|
$category = $data['categories'][0];
|
||||||
|
// Avant correctif : seuls @id/@type/createdAt/updatedAt (category:read
|
||||||
|
// absent du contexte). Apres : code + name (libelle) embarques.
|
||||||
|
self::assertArrayHasKey('code', $category);
|
||||||
|
self::assertArrayHasKey('name', $category);
|
||||||
|
self::assertNotSame('', $category['code']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAddressSitesEmbedLabel(): void
|
||||||
|
{
|
||||||
|
$this->skipIfSitesModuleDisabled();
|
||||||
|
|
||||||
|
$seed = $this->seedCompleteClient('Embed Site Co');
|
||||||
|
$id = $seed->getId();
|
||||||
|
|
||||||
|
$http = $this->createAdminClient();
|
||||||
|
$data = $http->request('GET', '/api/clients/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||||
|
|
||||||
|
$address = $data['addresses'][0];
|
||||||
|
self::assertArrayHasKey('sites', $address);
|
||||||
|
self::assertNotEmpty($address['sites']);
|
||||||
|
// Site embarque : libelle `name` present (avant : stub @id/@type nu).
|
||||||
|
// NB : Site n'a pas de champ `code` (cf. note de classe) -> on asserte name.
|
||||||
|
self::assertArrayHasKey('name', $address['sites'][0]);
|
||||||
|
self::assertNotSame('', $address['sites'][0]['name']);
|
||||||
|
|
||||||
|
// L'adresse seedee est multi-sites : preuve que l'embed parcourt la collection.
|
||||||
|
self::assertGreaterThanOrEqual(2, count($address['sites']));
|
||||||
|
|
||||||
|
// Categories d'adresse : code embarque (category:read dans le contexte).
|
||||||
|
self::assertArrayHasKey('categories', $address);
|
||||||
|
self::assertNotEmpty($address['categories']);
|
||||||
|
self::assertArrayHasKey('code', $address['categories'][0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Enveloppe AP4 (sans prefixe hydra:) + exclusion des archives ===
|
||||||
|
|
||||||
|
public function testCollectionEnvelopeShapeAndArchivedExcluded(): void
|
||||||
|
{
|
||||||
|
$http = $this->createAdminClient();
|
||||||
|
$prefix = 'EnvCheck'.substr(bin2hex(random_bytes(3)), 0, 6);
|
||||||
|
|
||||||
|
$this->seedClient($prefix.' Active');
|
||||||
|
$this->seedClient($prefix.' Archived', true);
|
||||||
|
|
||||||
|
// Liste par defaut filtree sur le prefixe : enveloppe member/totalItems
|
||||||
|
// sans prefixe hydra:, archive EXCLU du totalItems (RG-1.24).
|
||||||
|
$default = $http->request('GET', '/api/clients?search='.$prefix, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||||
|
|
||||||
|
self::assertArrayHasKey('member', $default);
|
||||||
|
self::assertArrayHasKey('totalItems', $default);
|
||||||
|
self::assertArrayNotHasKey('hydra:member', $default);
|
||||||
|
self::assertArrayNotHasKey('hydra:totalItems', $default);
|
||||||
|
self::assertSame(1, $default['totalItems'], 'Archive exclu du totalItems par defaut.');
|
||||||
|
|
||||||
|
// includeArchived : l'archive reintegre le total.
|
||||||
|
$all = $http->request('GET', '/api/clients?search='.$prefix.'&includeArchived=true', ['headers' => ['Accept' => self::LD]])->toArray();
|
||||||
|
self::assertSame(2, $all['totalItems']);
|
||||||
|
|
||||||
|
// `view` (PartialCollectionView) sans prefixe hydra: : force le multi-page
|
||||||
|
// via itemsPerPage=1 sur les 2 resultats archives inclus.
|
||||||
|
$paged = $http->request('GET', '/api/clients?search='.$prefix.'&includeArchived=true&itemsPerPage=1', ['headers' => ['Accept' => self::LD]])->toArray();
|
||||||
|
self::assertArrayHasKey('view', $paged);
|
||||||
|
self::assertArrayNotHasKey('hydra:view', $paged);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Helper ===
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seede un client COMPLET (sans passer par l'API, validations applicatives
|
||||||
|
* non rejouees mais CHECK BDD respectes) : bloc comptable non nul, >= 1 RIB,
|
||||||
|
* >= 1 adresse multi-sites avec categories, >= 1 contact, >= 1 categorie.
|
||||||
|
*
|
||||||
|
* L'adresse est livraison + facturation (prospect exclusif, RG-1.06 ; email
|
||||||
|
* de facturation present, RG-1.11) afin de poser des booleens `true`
|
||||||
|
* serialisables tout en respectant les CHECK Postgres.
|
||||||
|
*/
|
||||||
|
private function seedCompleteClient(string $companyName): ClientEntity
|
||||||
|
{
|
||||||
|
$em = $this->getEm();
|
||||||
|
|
||||||
|
// Nom unique parmi les actifs (index partiel uq_client_company_name_active).
|
||||||
|
$suffix = substr(bin2hex(random_bytes(3)), 0, 6);
|
||||||
|
|
||||||
|
$client = new ClientEntity();
|
||||||
|
$client->setCompanyName(mb_strtoupper($companyName.' '.$suffix, 'UTF-8'));
|
||||||
|
$client->setLastName('Complet');
|
||||||
|
$client->setPhonePrimary('0102030405');
|
||||||
|
$client->setEmail('complet'.$suffix.'@seed.test');
|
||||||
|
$client->addCategory($this->createCategory('SECTEUR'));
|
||||||
|
// Bloc comptable non nul (gating par omission cote Commerciale).
|
||||||
|
$client->setSiren('123456789');
|
||||||
|
$client->setAccountNumber('C0001');
|
||||||
|
$client->setNTva('FR00123456789');
|
||||||
|
$em->persist($client);
|
||||||
|
|
||||||
|
// >= 2 sites fixtures pour une adresse multi-sites (RG-1.10).
|
||||||
|
$sites = $em->getRepository(Site::class)->findBy([], null, 2);
|
||||||
|
self::assertGreaterThanOrEqual(2, count($sites), 'Au moins 2 sites fixtures requis (SitesFixtures).');
|
||||||
|
|
||||||
|
$address = new ClientAddress();
|
||||||
|
$address->setClient($client);
|
||||||
|
$address->setIsProspect(false);
|
||||||
|
$address->setIsDelivery(true);
|
||||||
|
$address->setIsBilling(true);
|
||||||
|
$address->setBillingEmail('billing'.$suffix.'@seed.test');
|
||||||
|
$address->setPostalCode('86000');
|
||||||
|
$address->setCity('Poitiers');
|
||||||
|
$address->setStreet('12 rue des Acacias');
|
||||||
|
foreach ($sites as $site) {
|
||||||
|
$address->addSite($site);
|
||||||
|
}
|
||||||
|
$address->addCategory($this->createCategory('SECTEUR'));
|
||||||
|
$em->persist($address);
|
||||||
|
|
||||||
|
$rib = new ClientRib();
|
||||||
|
$rib->setClient($client);
|
||||||
|
$rib->setLabel('Compte principal');
|
||||||
|
$rib->setBic(self::VALID_BIC);
|
||||||
|
$rib->setIban(self::VALID_IBAN);
|
||||||
|
$em->persist($rib);
|
||||||
|
|
||||||
|
$contact = new ClientContact();
|
||||||
|
$contact->setClient($client);
|
||||||
|
$contact->setFirstName('Marie');
|
||||||
|
$contact->setLastName('Martin');
|
||||||
|
$em->persist($contact);
|
||||||
|
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user