Merge branch 'develop' into feature/ERP-116-country-referentiel
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m10s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m14s

This commit is contained in:
2026-06-11 07:38:35 +00:00
43 changed files with 6121 additions and 602 deletions
@@ -0,0 +1,95 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Mocks des composables auto-importes par Nuxt (indisponibles sous happy-dom).
const mockGet = vi.hoisted(() => vi.fn())
const mockPatch = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({
get: mockGet,
post: vi.fn(),
put: vi.fn(),
patch: mockPatch,
delete: vi.fn(),
}))
const { useSupplier } = await import('../useSupplier')
const SAMPLE = { '@id': '/api/suppliers/85', id: 85, companyName: 'DOD59393F 862875', isArchived: false }
describe('useSupplier', () => {
beforeEach(() => {
mockGet.mockReset()
mockPatch.mockReset()
mockGet.mockResolvedValue(SAMPLE)
mockPatch.mockResolvedValue({ ...SAMPLE, isArchived: true })
})
it('charge le detail via GET /suppliers/{id} en Hydra, sans toast', async () => {
const { supplier, load } = useSupplier(85)
await load()
expect(mockGet).toHaveBeenCalledWith(
'/suppliers/85',
{},
expect.objectContaining({
headers: { Accept: 'application/ld+json' },
toast: false,
}),
)
expect(supplier.value).toEqual(SAMPLE)
})
it('bascule loading pendant le chargement et le retombe a false', async () => {
const { loading, load } = useSupplier(85)
const promise = load()
expect(loading.value).toBe(true)
await promise
expect(loading.value).toBe(false)
})
it('marque error et laisse supplier null si le GET echoue (404...)', async () => {
mockGet.mockRejectedValueOnce(new Error('not found'))
const { supplier, error, load } = useSupplier(99)
await load()
expect(error.value).toBe(true)
expect(supplier.value).toBeNull()
})
it('archive() PATCHe { isArchived: true } sans toast puis RECHARGE le detail complet', async () => {
// 1er GET = chargement initial, 2e GET = rechargement post-archivage.
mockGet.mockResolvedValueOnce(SAMPLE)
mockGet.mockResolvedValueOnce({ ...SAMPLE, isArchived: true })
const { supplier, load, archive } = useSupplier(85)
await load()
await archive()
expect(mockPatch).toHaveBeenCalledWith(
'/suppliers/85',
{ isArchived: true },
expect.objectContaining({ toast: false }),
)
// Le detail est re-fetch (le PATCH ne renvoie pas l'embed complet).
expect(mockGet).toHaveBeenCalledTimes(2)
expect(supplier.value?.isArchived).toBe(true)
})
it('restore() PATCHe { isArchived: false } (payload isArchived SEUL)', async () => {
const { load, restore } = useSupplier(85)
await load()
await restore()
expect(mockPatch).toHaveBeenCalledWith(
'/suppliers/85',
{ isArchived: false },
expect.objectContaining({ toast: false }),
)
})
it('propage l\'erreur (ex: 403 sans permission archive, 409 conflit homonyme) au lieu de l\'avaler', async () => {
const forbidden = { response: { status: 403 } }
mockPatch.mockRejectedValueOnce(forbidden)
const { load, archive } = useSupplier(85)
await load()
await expect(archive()).rejects.toBe(forbidden)
})
})
@@ -0,0 +1,63 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// `useApi` est un auto-import Nuxt : on le stubbe globalement pour intercepter
// les appels de chargement des referentiels et controler les reponses Hydra.
const mockGet = vi.hoisted(() => vi.fn())
vi.stubGlobal('useApi', () => ({ get: mockGet }))
const { useSupplierReferentials } = await import('../useSupplierReferentials')
describe('useSupplierReferentials', () => {
beforeEach(() => {
mockGet.mockReset()
mockGet.mockResolvedValue({ member: [] })
})
it('charge les categories filtrees sur le type FOURNISSEUR (RG-2.10)', async () => {
await useSupplierReferentials().loadCommon()
expect(mockGet).toHaveBeenCalledWith(
'/categories',
expect.objectContaining({ pagination: 'false', typeCode: 'FOURNISSEUR' }),
expect.objectContaining({ toast: false }),
)
})
it('mappe les categories en options { value: IRI, label: name, code }', async () => {
mockGet.mockImplementation((url: string) => {
if (url === '/categories') {
return Promise.resolve({ member: [{ '@id': '/api/categories/9', code: 'NEGOCIANT', name: 'Négociant' }] })
}
return Promise.resolve({ member: [] })
})
const refs = useSupplierReferentials()
await refs.loadCommon()
expect(refs.categories.value).toEqual([{ value: '/api/categories/9', label: 'Négociant', code: 'NEGOCIANT' }])
})
it('ne charge ni distributeurs ni courtiers (absents du modele fournisseur)', async () => {
await useSupplierReferentials().loadCommon()
const urls = mockGet.mock.calls.map(c => c[0])
expect(urls).not.toContain('/clients')
expect(urls).toEqual(
expect.arrayContaining(['/categories', '/sites', '/tva_modes', '/payment_delays', '/payment_types', '/banks']),
)
})
it('reste resilient : un referentiel en echec n\'empeche pas les autres', async () => {
mockGet.mockImplementation((url: string) => {
if (url === '/categories') return Promise.reject(new Error('403'))
if (url === '/banks') return Promise.resolve({ member: [{ '@id': '/api/banks/1', code: 'SG', label: 'Société Générale' }] })
return Promise.resolve({ member: [] })
})
const refs = useSupplierReferentials()
await refs.loadCommon()
expect(refs.categories.value).toEqual([])
expect(refs.banks.value).toEqual([{ value: '/api/banks/1', label: 'Société Générale' }])
})
})
@@ -0,0 +1,85 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { HydraCollection } from '~/shared/utils/api'
import type { Supplier } from '../useSuppliersRepository'
// `useApi` est un auto-import Nuxt : on le stubbe globalement pour intercepter
// les appels declenches par usePaginatedList (que useSuppliersRepository enveloppe)
// et controler les reponses. Meme pattern que useClientsRepository.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 { useSuppliersRepository } = await import('../useSuppliersRepository')
/** Envelope Hydra minimale (la liste reelle des membres importe peu ici). */
function makeHydra(total: number): HydraCollection<Supplier> {
return { totalItems: total, member: [] }
}
describe('useSuppliersRepository', () => {
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 /suppliers en page 1 par defaut', async () => {
const repo = useSuppliersRepository()
await repo.fetch()
expect(mockGet).toHaveBeenLastCalledWith(
'/suppliers',
{ page: 1, itemsPerPage: 10 },
expect.objectContaining({ toast: false }),
)
})
it('pousse les filtres du drawer (categories multi, sites, archives inclus) et retombe en page 1', async () => {
const repo = useSuppliersRepository()
await repo.fetch()
await repo.goToPage(2)
expect(repo.currentPage.value).toBe(2)
await repo.setFilters(
{
search: 'acme',
'categoryCode[]': ['NEGOCIANT', 'TRANSPORTEUR'],
'siteId[]': ['86', '17'],
includeArchived: true,
},
{ replace: true },
)
expect(repo.currentPage.value).toBe(1)
expect(mockGet).toHaveBeenLastCalledWith(
'/suppliers',
{
search: 'acme',
'categoryCode[]': ['NEGOCIANT', 'TRANSPORTEUR'],
'siteId[]': ['86', '17'],
includeArchived: true,
page: 1,
itemsPerPage: 10,
},
expect.objectContaining({ toast: false }),
)
})
it('repasse a une query propre apres reinitialisation des filtres', async () => {
const repo = useSuppliersRepository()
await repo.setFilters({ search: 'acme', includeArchived: true }, { replace: true })
await repo.setFilters({}, { replace: true })
expect(mockGet).toHaveBeenLastCalledWith(
'/suppliers',
{ page: 1, itemsPerPage: 10 },
expect.objectContaining({ toast: false }),
)
})
})
@@ -0,0 +1,71 @@
import { ref } from 'vue'
import type { SupplierDetail } from '~/modules/commercial/utils/supplierConsultation'
/**
* Chargement et actions d'archivage d'un fournisseur unique (ecran « Consultation
* fournisseur », ERP-95). Miroir de `useClient` (M1). Lit le detail embarque via
* `GET /api/suppliers/{id}` (contacts / adresses / ribs sous `supplier:item:read` /
* `supplier:read:accounting`) et expose les bascules d'archivage (PATCH `isArchived`
* SEUL — tout autre champ => 422).
*
* L'en-tete `Accept: application/ld+json` est impose pour obtenir le payload
* Hydra complet (sans lui, API Platform 4 renvoie une representation reduite).
*
* Etat 100 % local a l'instance (refs) — aucune persistance URL. Les erreurs
* d'archivage/restauration (notamment le 409 d'homonyme actif a la restauration)
* sont PROPAGEES a l'appelant, qui decide du toast a afficher.
*/
export function useSupplier(id: number | string) {
const api = useApi()
const supplier = ref<SupplierDetail | null>(null)
const loading = ref(false)
const error = ref(false)
/** Recupere le detail complet (embed contacts/adresses/ribs + comptabilite). */
function fetchDetail(): Promise<SupplierDetail> {
return api.get<SupplierDetail>(
`/suppliers/${id}`,
{},
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
}
/** Charge le detail du fournisseur. En cas d'echec : `error = true`, `supplier = null`. */
async function load(): Promise<void> {
loading.value = true
error.value = false
try {
supplier.value = await fetchDetail()
}
catch {
error.value = true
supplier.value = null
}
finally {
loading.value = false
}
}
/**
* Bascule l'archivage (PATCH `isArchived` SEUL — tout autre champ => 422),
* puis RECHARGE le detail complet : la reponse du PATCH ne porte que le groupe
* `supplier:read` (ni l'embed contacts/adresses/ribs ni les libelles des
* referentiels comptables), un simple merge laisserait l'affichage incoherent.
* Toute erreur (notamment le 409 d'homonyme actif a la restauration) est
* propagee a l'appelant AVANT le rechargement.
*/
async function setArchived(isArchived: boolean): Promise<void> {
await api.patch(`/suppliers/${id}`, { isArchived }, { toast: false })
supplier.value = await fetchDetail()
}
return {
supplier,
loading,
error,
load,
archive: () => setArchived(true),
restore: () => setArchived(false),
}
}
@@ -0,0 +1,88 @@
/**
* Composable d'erreurs partage des ecrans fournisseur (creation + edition, M2
* Commercial). Miroir de `useClientFormErrors` (M1) :
* - un `useFormErrors` par groupe scalaire (Principal / Information /
* Comptabilite) : violations 422 affichees inline sous chaque champ ;
* - un tableau d'erreurs PAR LIGNE pour chaque collection (contacts /
* adresses / RIB), aligne sur l'index du `v-for`.
*
* `mapRowError` ne toaste PAS lui-meme : il retourne un booleen (true = mappe
* inline). Chaque page conserve ainsi son propre fallback dans le `catch`.
*/
import { ref, type Ref } from 'vue'
import { mapViolationsToRecord } from '~/shared/utils/api'
export function useSupplierFormErrors() {
const mainErrors = useFormErrors()
const informationErrors = useFormErrors()
const accountingErrors = useFormErrors()
const contactErrors = ref<Record<string, string>[]>([])
const addressErrors = ref<Record<string, string>[]>([])
const ribErrors = ref<Record<string, string>[]>([])
/**
* Mappe l'erreur d'une ligne de collection sur le tableau cible (par index).
* 422 avec violations exploitables → erreurs inline sous les champs de la
* ligne + retourne true. Sinon → ne touche pas la cible et retourne false.
*/
function mapRowError(
error: unknown,
target: Ref<Record<string, string>[]>,
index: number,
): boolean {
const response = (error as { response?: { status?: number, _data?: unknown } })?.response
const mapped = response?.status === 422 ? mapViolationsToRecord(response._data) : {}
if (Object.keys(mapped).length > 0) {
target.value[index] = mapped
return true
}
return false
}
/**
* Soumet TOUS les blocs d'une collection (contacts / adresses / RIB) en
* collectant les erreurs par index : on n'arrete PAS au premier bloc en echec
* (decision ERP-110 / ERP-101). Reinitialise le tableau d'erreurs cible, tente
* chaque ligne via `saveRow`, mappe les 422 inline (mapRowError) ou delegue le
* fallback a `onUnmappedError`. `shouldSkip` permet d'ignorer les blocs vides.
* Retourne true si au moins un bloc a echoue.
*/
async function submitRows<T>(
rows: T[],
target: Ref<Record<string, string>[]>,
saveRow: (row: T, index: number) => Promise<void>,
onUnmappedError: (error: unknown, index: number) => void,
shouldSkip?: (row: T, index: number) => boolean,
): Promise<boolean> {
target.value = []
let hasError = false
for (let index = 0; index < rows.length; index++) {
const row = rows[index] as T
if (shouldSkip?.(row, index)) {
continue
}
try {
await saveRow(row, index)
}
catch (error) {
if (!mapRowError(error, target, index)) {
onUnmappedError(error, index)
}
hasError = true
}
}
return hasError
}
return {
mainErrors,
informationErrors,
accountingErrors,
contactErrors,
addressErrors,
ribErrors,
mapRowError,
submitRows,
}
}
@@ -0,0 +1,118 @@
import { ref } from 'vue'
/**
* Charge les referentiels (listes courtes) alimentant les selects de l'ecran
* « Ajouter un fournisseur » : categories (type FOURNISSEUR), sites, modes de TVA,
* delais et types de reglement, banques. Miroir de `useClientReferentials` (M1).
*
* 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`)
* renvoyees telles quelles dans les payloads POST/PATCH (relations M:1 / M:N).
*
* Difference M2 : pas de distributeurs/courtiers (absents du modele fournisseur).
*
* Etat 100 % local a l'instance (refs) — aucune persistance URL.
*/
/** Option generique au format attendu par MalioSelect / MalioSelectCheckbox. */
export interface RefOption {
value: string
label: string
}
/** Option de type de reglement enrichie de son code stable (RG-2.07 / RG-2.08). */
export interface PaymentTypeOption extends RefOption {
code: string
}
/** Option de categorie enrichie de son code stable. */
export interface CategoryOption extends RefOption {
code: string
}
interface HydraMember {
'@id': string
}
interface CategoryMember extends HydraMember {
code: string
name: string
}
interface SiteMember extends HydraMember {
name: string
postalCode: string
}
interface ReferentialMember extends HydraMember {
code: string
label: string
}
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
export function useSupplierReferentials() {
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[]>([])
/** 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.
*
* Chargement RESILIENT (Promise.allSettled) : chaque referentiel est isole.
* Necessaire pour les roles metier qui n'ont pas toutes les permissions de
* lecture (ex. Compta a `commercial.suppliers.view` mais pas forcement
* `catalog.categories.view` ni `sites.view`). Un referentiel en echec reste
* simplement vide.
*/
async function loadCommon(): Promise<void> {
await Promise.allSettled([
// Taxonomie multi-types (ERP-84) : un fournisseur ne porte que des
// categories de type FOURNISSEUR (RG-2.10) -> on filtre cote API.
fetchAll<CategoryMember>('/categories', { typeCode: 'FOURNISSEUR' })
.then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }),
fetchAll<SiteMember>('/sites')
// Libelle = numero de departement (2 premiers chiffres du code
// postal du site), ex: 86100 -> « 86 ».
.then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2) })) }),
fetchAll<ReferentialMember>('/tva_modes')
.then((tva) => { tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label })) }),
fetchAll<ReferentialMember>('/payment_delays')
.then((delays) => { paymentDelays.value = delays.map(d => ({ value: d['@id'], label: d.label })) }),
fetchAll<ReferentialMember>('/payment_types')
.then((types) => { paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code })) }),
fetchAll<ReferentialMember>('/banks')
.then((banksList) => { banks.value = banksList.map(b => ({ value: b['@id'], label: b.label })) }),
])
}
return {
categories,
sites,
tvaModes,
paymentDelays,
paymentTypes,
banks,
loadCommon,
}
}
@@ -0,0 +1,54 @@
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
/**
* Site Starseed rattache a une adresse du fournisseur, tel qu'embarque en LISTE
* (groupe site:read) pour la colonne « Site » du Repertoire (badges colores).
* Agrege des adresses cote back via Supplier::getSites() (cf. spec-back M2).
*/
export interface SupplierSite {
id: number
name: string
color: string
}
/**
* Categorie (type FOURNISSEUR) rattachee au fournisseur, embarquee en LISTE
* (groupe category:read). La colonne « Catégories » affiche le `name` (et non le
* `code` comme au M1 clients — decision spec-front M2 § Datatable).
*/
export interface SupplierCategory {
code: string
name: string
}
/**
* Vue MINIMALE d'un fournisseur 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-93).
*/
export interface Supplier {
id: number
companyName: string
categories: SupplierCategory[]
sites: SupplierSite[]
/** Date ISO de derniere modification (default:read) — colonne « Dernière activité ». */
updatedAt: string | null
isArchived: boolean
}
/**
* Repertoire fournisseurs (ERP-93) — simple enveloppe de `usePaginatedList<Supplier>`
* sur la ressource `/suppliers` (RG-13 : pagination serveur obligatoire ; jamais
* de chargement integral en memoire). Miroir de `useClientsRepository` (M1).
*
* Les filtres (recherche, categories, sites, inclusion des 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`. Aucun reset au logout a gerer.
*/
export function useSuppliersRepository() {
return usePaginatedList<Supplier>({ url: '/suppliers' })
}