feat(commercial) : filtres répertoire clients via drawer (recherche, catégories, sites, archivés)
Front : - Bouton « Filtres » (à droite d'Ajouter) ouvrant un drawer accordion (façon audit-log) : Recherche, Catégories (multi), Sites (multi), Statut (archivés). État brouillon → appliqué, 100 % local. Compteur de filtres actifs sur le bouton. - Suppression du toggle « Voir les archivés » (remplacé par le bool du drawer). - Export et liste partagent les mêmes filtres. - useClientsRepository redevient un simple wrapper de usePaginatedList. Back (contrat liste partagé liste + export) : - createListQueryBuilder : categoryCodes[] (OR), siteIds[] (clients ayant ≥1 adresse sur le site), archivedOnly (archives seules, prioritaire sur includeArchived). search inchangé. - ClientProvider + ClientExportController lisent les nouveaux params (valeur unique ou liste ?key[]=). Tests fonctionnels (catégories multi, site, archivés).
This commit is contained in:
@@ -49,7 +49,6 @@
|
|||||||
"title": "Répertoire clients",
|
"title": "Répertoire clients",
|
||||||
"add": "Ajouter",
|
"add": "Ajouter",
|
||||||
"export": "Exporter",
|
"export": "Exporter",
|
||||||
"showArchived": "Voir les archivés",
|
|
||||||
"empty": "Aucun client pour l'instant.",
|
"empty": "Aucun client pour l'instant.",
|
||||||
"column": {
|
"column": {
|
||||||
"companyName": "Nom",
|
"companyName": "Nom",
|
||||||
@@ -57,6 +56,16 @@
|
|||||||
"sites": "Site",
|
"sites": "Site",
|
||||||
"lastActivity": "Dernière activité"
|
"lastActivity": "Dernière activité"
|
||||||
},
|
},
|
||||||
|
"filters": {
|
||||||
|
"title": "Filtres",
|
||||||
|
"search": "Recherche",
|
||||||
|
"categories": "Catégories",
|
||||||
|
"sites": "Sites",
|
||||||
|
"status": "Statut",
|
||||||
|
"archivedOnly": "Voir les archivés",
|
||||||
|
"apply": "Appliquer",
|
||||||
|
"reset": "Réinitialiser"
|
||||||
|
},
|
||||||
"tab": {
|
"tab": {
|
||||||
"information": "Information",
|
"information": "Information",
|
||||||
"contact": "Contact",
|
"contact": "Contact",
|
||||||
|
|||||||
@@ -29,11 +29,10 @@ describe('useClientsRepository', () => {
|
|||||||
mockGet.mockResolvedValue(makeHydra(25))
|
mockGet.mockResolvedValue(makeHydra(25))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('charge /clients sans includeArchived par defaut (clients actifs)', async () => {
|
it('cible la ressource /clients en page 1 par defaut', async () => {
|
||||||
const repo = useClientsRepository()
|
const repo = useClientsRepository()
|
||||||
await repo.fetch()
|
await repo.fetch()
|
||||||
|
|
||||||
expect(repo.includeArchived.value).toBe(false)
|
|
||||||
expect(mockGet).toHaveBeenLastCalledWith(
|
expect(mockGet).toHaveBeenLastCalledWith(
|
||||||
'/clients',
|
'/clients',
|
||||||
{ page: 1, itemsPerPage: 10 },
|
{ page: 1, itemsPerPage: 10 },
|
||||||
@@ -41,35 +40,42 @@ describe('useClientsRepository', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('pousse le filtre serveur includeArchived=true quand le toggle est actif', async () => {
|
it('pousse les filtres du drawer (categories multi, sites, archives) et retombe en page 1', async () => {
|
||||||
const repo = useClientsRepository()
|
|
||||||
await repo.fetch()
|
|
||||||
await repo.setIncludeArchived(true)
|
|
||||||
|
|
||||||
expect(repo.includeArchived.value).toBe(true)
|
|
||||||
expect(mockGet).toHaveBeenLastCalledWith(
|
|
||||||
'/clients',
|
|
||||||
{ includeArchived: true, page: 1, itemsPerPage: 10 },
|
|
||||||
expect.objectContaining({ toast: false }),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('retombe en page 1 lorsqu on bascule le toggle archives', async () => {
|
|
||||||
const repo = useClientsRepository()
|
const repo = useClientsRepository()
|
||||||
await repo.fetch()
|
await repo.fetch()
|
||||||
await repo.goToPage(2)
|
await repo.goToPage(2)
|
||||||
expect(repo.currentPage.value).toBe(2)
|
expect(repo.currentPage.value).toBe(2)
|
||||||
|
|
||||||
await repo.setIncludeArchived(true)
|
await repo.setFilters(
|
||||||
|
{
|
||||||
|
search: 'acme',
|
||||||
|
'categoryCode[]': ['DISTRIBUTEUR', 'COURTIER'],
|
||||||
|
'siteId[]': ['1', '2'],
|
||||||
|
archivedOnly: true,
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
)
|
||||||
|
|
||||||
expect(repo.currentPage.value).toBe(1)
|
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('retire le filtre (query propre) quand le toggle repasse a false', async () => {
|
it('repasse a une query propre apres reinitialisation des filtres', async () => {
|
||||||
const repo = useClientsRepository()
|
const repo = useClientsRepository()
|
||||||
await repo.setIncludeArchived(true)
|
await repo.setFilters({ search: 'acme', archivedOnly: true }, { replace: true })
|
||||||
await repo.setIncludeArchived(false)
|
await repo.setFilters({}, { replace: true })
|
||||||
|
|
||||||
expect(repo.includeArchived.value).toBe(false)
|
|
||||||
expect(mockGet).toHaveBeenLastCalledWith(
|
expect(mockGet).toHaveBeenLastCalledWith(
|
||||||
'/clients',
|
'/clients',
|
||||||
{ page: 1, itemsPerPage: 10 },
|
{ page: 1, itemsPerPage: 10 },
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { ref } from 'vue'
|
|
||||||
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
|
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,11 +40,8 @@ export interface Client {
|
|||||||
* sur la ressource `/clients` (RG-13 : pagination serveur obligatoire ; jamais
|
* sur la ressource `/clients` (RG-13 : pagination serveur obligatoire ; jamais
|
||||||
* de chargement integral en memoire).
|
* de chargement integral en memoire).
|
||||||
*
|
*
|
||||||
* N'ajoute qu'un seul comportement metier : le toggle « Voir les archivés ».
|
* Les filtres (recherche, categories, sites, archives) sont pilotes par la page
|
||||||
* Desactive par defaut (la liste n'expose que les clients actifs — RG-1.24).
|
* via `setFilters` du composable partage — la remise en page 1 est garantie.
|
||||||
* Active, il pousse le filtre serveur `?includeArchived=true` (consomme par le
|
|
||||||
* ClientProvider, RG-1.25) et — garantie de `usePaginatedList` — retombe en
|
|
||||||
* page 1.
|
|
||||||
*
|
*
|
||||||
* Volontairement PAR INSTANCE (pas de singleton module-level) : l'etat tableau
|
* 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
|
* est propre a l'ecran Repertoire et meurt avec lui, comme tout consommateur de
|
||||||
@@ -53,28 +49,5 @@ export interface Client {
|
|||||||
* gerer.
|
* gerer.
|
||||||
*/
|
*/
|
||||||
export function useClientsRepository() {
|
export function useClientsRepository() {
|
||||||
// Etat local du toggle « Voir les archivés » — JAMAIS reflete dans l'URL
|
return usePaginatedList<Client>({ url: '/clients' })
|
||||||
// (regle ABSOLUE n°6).
|
|
||||||
const includeArchived = ref(false)
|
|
||||||
|
|
||||||
const list = usePaginatedList<Client>({ url: '/clients' })
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bascule l'inclusion des clients archives et relance la liste. La remise
|
|
||||||
* en page 1 est assuree par `setFilters` (usePaginatedList). Quand le toggle
|
|
||||||
* repasse a false, on RETIRE le filtre (valeur `undefined`) plutot que
|
|
||||||
* d'envoyer `includeArchived=false`, pour une query propre.
|
|
||||||
*/
|
|
||||||
async function setIncludeArchived(value: boolean): Promise<void> {
|
|
||||||
includeArchived.value = value
|
|
||||||
await list.setFilters(
|
|
||||||
value ? { includeArchived: true } : { includeArchived: undefined },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...list,
|
|
||||||
includeArchived,
|
|
||||||
setIncludeArchived,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,20 +11,19 @@
|
|||||||
icon-position="left"
|
icon-position="left"
|
||||||
@click="goToCreate"
|
@click="goToCreate"
|
||||||
/>
|
/>
|
||||||
|
<!-- Bouton Filtres a DROITE d'Ajouter : ouvre le drawer. Le compteur
|
||||||
|
reflete le nombre de filtres actifs (etat applique). -->
|
||||||
|
<MalioButton
|
||||||
|
v-if="canView"
|
||||||
|
variant="tertiary"
|
||||||
|
:label="filterButtonLabel"
|
||||||
|
icon-name="mdi:tune"
|
||||||
|
icon-position="left"
|
||||||
|
@click="openFilters"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<!-- Toggle « Voir les archivés » : etat 100 % local (regle ABSOLUE n°6),
|
|
||||||
desactive par defaut (clients actifs uniquement). -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<MalioCheckbox
|
|
||||||
id="clients-include-archived"
|
|
||||||
:label="t('commercial.clients.showArchived')"
|
|
||||||
:model-value="includeArchived"
|
|
||||||
@update:model-value="onToggleArchived"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Datatable branchee sur usePaginatedList via useClientsRepository :
|
<!-- Datatable branchee sur usePaginatedList via useClientsRepository :
|
||||||
pagination serveur, tri companyName ASC par defaut (cote back). -->
|
pagination serveur, tri companyName ASC par defaut (cote back). -->
|
||||||
<MalioDataTable
|
<MalioDataTable
|
||||||
@@ -65,6 +64,7 @@
|
|||||||
{{ formatLastActivity(item) }}
|
{{ formatLastActivity(item) }}
|
||||||
</template>
|
</template>
|
||||||
</MalioDataTable>
|
</MalioDataTable>
|
||||||
|
|
||||||
<div class="flex justify-center mt-6">
|
<div class="flex justify-center mt-6">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
v-if="canView"
|
v-if="canView"
|
||||||
@@ -74,12 +74,93 @@
|
|||||||
@click="exportXlsx"
|
@click="exportXlsx"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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')"
|
||||||
|
@click="resetFilters"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
:label="t('commercial.clients.filters.apply')"
|
||||||
|
@click="applyFilters"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MalioDrawer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import type { Client, ClientSite } from '~/modules/commercial/composables/useClientsRepository'
|
import type { Client, ClientSite } from '~/modules/commercial/composables/useClientsRepository'
|
||||||
|
|
||||||
|
interface FilterOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -88,8 +169,8 @@ const { can } = usePermissions()
|
|||||||
|
|
||||||
useHead({ title: t('commercial.clients.title') })
|
useHead({ title: t('commercial.clients.title') })
|
||||||
|
|
||||||
// Bouton « + Ajouter » reserve a `manage` (POST /clients garde manage seul →
|
// Bouton « Ajouter » reserve a `manage` (POST /clients garde manage seul →
|
||||||
// Compta / Usine ne creent pas). « Exporter » suit `view`.
|
// Compta / Usine ne creent pas). « Exporter » et « Filtres » suivent `view`.
|
||||||
const canManage = computed(() => can('commercial.clients.manage'))
|
const canManage = computed(() => can('commercial.clients.manage'))
|
||||||
const canView = computed(() => can('commercial.clients.view'))
|
const canView = computed(() => can('commercial.clients.view'))
|
||||||
|
|
||||||
@@ -99,11 +180,10 @@ const {
|
|||||||
currentPage,
|
currentPage,
|
||||||
itemsPerPage,
|
itemsPerPage,
|
||||||
itemsPerPageOptions,
|
itemsPerPageOptions,
|
||||||
includeArchived,
|
|
||||||
fetch: loadClients,
|
fetch: loadClients,
|
||||||
goToPage,
|
goToPage,
|
||||||
setItemsPerPage,
|
setItemsPerPage,
|
||||||
setIncludeArchived,
|
setFilters,
|
||||||
} = useClientsRepository()
|
} = useClientsRepository()
|
||||||
|
|
||||||
// Mappe les clients en objets « plats » pour MalioDataTable (items typees
|
// Mappe les clients en objets « plats » pour MalioDataTable (items typees
|
||||||
@@ -153,12 +233,127 @@ function goToCreate(): void {
|
|||||||
router.push('/clients/new')
|
router.push('/clients/new')
|
||||||
}
|
}
|
||||||
|
|
||||||
function onToggleArchived(value: boolean): void {
|
// ── Filtres (drawer) ────────────────────────────────────────────────────────
|
||||||
setIncludeArchived(value)
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export XLSX : memes filtres que la vue (includeArchived). La colonne SIREN
|
function toggleCategory(code: string, selected: boolean): void {
|
||||||
// n'est dans le fichier que si l'utilisateur a accounting.view (gere cote back).
|
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)
|
const exporting = ref(false)
|
||||||
|
|
||||||
async function exportXlsx(): Promise<void> {
|
async function exportXlsx(): Promise<void> {
|
||||||
@@ -167,16 +362,11 @@ async function exportXlsx(): Promise<void> {
|
|||||||
}
|
}
|
||||||
exporting.value = true
|
exporting.value = true
|
||||||
try {
|
try {
|
||||||
const query: Record<string, unknown> = {}
|
|
||||||
if (includeArchived.value) {
|
|
||||||
query.includeArchived = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// useApi type ses options en JSON ; l'export renvoie un binaire, donc on
|
// useApi type ses options en JSON ; l'export renvoie un binaire, donc on
|
||||||
// force responseType:'blob' (transmis tel quel a ofetch au runtime). Cast
|
// force responseType:'blob' (transmis tel quel a ofetch au runtime). Cast
|
||||||
// contenu faute d'overload blob sur le client partage — a generaliser via
|
// contenu faute d'overload blob sur le client partage — a generaliser via
|
||||||
// un ticket dedie si d'autres exports binaires arrivent.
|
// un ticket dedie si d'autres exports binaires arrivent.
|
||||||
const blob = await api.get<Blob>('/clients/export.xlsx', query, {
|
const blob = await api.get<Blob>('/clients/export.xlsx', buildFilterPayload(), {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
toast: false,
|
toast: false,
|
||||||
} as unknown as Parameters<typeof api.get>[2])
|
} as unknown as Parameters<typeof api.get>[2])
|
||||||
@@ -208,5 +398,11 @@ function triggerDownload(blob: Blob, filename: string): void {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadClients()
|
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>
|
</script>
|
||||||
|
|||||||
@@ -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,7 +34,9 @@ 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
|
// Jointures + addSelect pour hydrater en une seule requete les
|
||||||
@@ -50,12 +52,16 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
|
|||||||
->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;
|
||||||
}
|
}
|
||||||
@@ -82,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,11 +107,67 @@ 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.
|
||||||
|
*
|
||||||
|
* @param list<string> $values
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function normalizeStringList(array $values): array
|
||||||
|
{
|
||||||
|
$cleaned = array_filter(
|
||||||
|
array_map(static fn (string $v): string => trim($v), $values),
|
||||||
|
static fn (string $v): bool => '' !== $v,
|
||||||
|
);
|
||||||
|
|
||||||
|
return array_values($cleaned);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nettoie une liste d'identifiants : cast int, retrait des <= 0, reindexation.
|
||||||
|
*
|
||||||
|
* @param list<int> $values
|
||||||
|
*
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
private function normalizeIntList(array $values): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter($values, static fn (int $v): bool => $v > 0));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ 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\Commercial\Domain\Entity\ClientAddress;
|
||||||
use App\Module\Sites\Domain\Entity\Site;
|
use App\Module\Sites\Domain\Entity\Site;
|
||||||
|
|
||||||
@@ -380,4 +382,94 @@ final class ClientApiTest extends AbstractCommercialApiTestCase
|
|||||||
self::assertArrayHasKey('color', $row['sites'][0]);
|
self::assertArrayHasKey('color', $row['sites'][0]);
|
||||||
self::assertSame($site->getName(), $row['sites'][0]['name']);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user