feat(commercial) : referentiel pays (country) en base + branchement front (ERP-116) (#79)
Auto Tag Develop / tag (push) Successful in 9s

## Objectif (ERP-116, 1re iteration minimale)

Sortir la liste des pays du **code en dur** cote front et la poser en base comme **referentiel `country`**, source unique du select pays. **Perimetre volontairement minimal** : code ISO + libelle + ordre uniquement — **aucune longueur bancaire/fiscale** (numero de compte, IBAN, TVA, BIC, SIREN) a ce stade.

## Backend
- Entite `Country` (`code` ISO 3166-1 alpha-2 unique, `name`, `position`), calquee sur `Bank` : referentiel statique **lecture seule** (`GetCollection` + `Get`), gating `commercial.clients.view OR commercial.suppliers.view`.
- Migration `Version20260609100000` : table `country` + `COMMENT ON COLUMN` + seed des **6 pays** (France, Allemagne, Belgique, Espagne, Italie, Royaume-Uni), `ON CONFLICT DO NOTHING`.
- `CommercialReferentialFixtures` : re-seed des pays en dev/test.
- Garde-fous : ajout au `ColumnCommentsCatalog` + whitelist `EntitiesAreTimestampableBlamableTest`.

## Frontend
- `useClientReferentials` charge `/countries` (value = **nom** du pays : l'adresse stocke `country` en chaine libre, **pas de FK ni migration de donnees**).
- Les 3 listes `countryOptions` en dur (clients new / edit / consultation) sont supprimees ; la consultation derive ses options de l'embed.

## Hors-scope (iterations suivantes du ticket)
- Longueurs bancaires/fiscales par pays + validation associee.
- FK `country_id` sur les adresses + migration de donnees.

## Tests
- Back : suite complete verte (583), tests API dedies countries (200/seed/405/403/401).
- Front : Vitest vert (256), spec `useClientReferentials` mise a jour.
- Migration appliquee en dev + test.

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #79
This commit was merged in pull request #79.
This commit is contained in:
2026-06-11 08:09:38 +00:00
parent c1ce940c98
commit 3f356f0679
17 changed files with 492 additions and 30 deletions
@@ -30,6 +30,10 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
if (url === '/sites') {
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault', postalCode: '86100' }] })
}
if (url === '/countries') {
// Pays : value === label === name (l'adresse stocke le nom).
return Promise.resolve({ member: [{ '@id': '/api/countries/1', code: 'FR', name: 'France' }] })
}
return Promise.resolve({
member: [{ '@id': '/api/x/1', code: 'X', label: 'Libelle X' }],
})
@@ -44,6 +48,8 @@ describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: '86' }])
expect(refs.tvaModes.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }])
expect(refs.banks.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }])
// Pays : value = nom du pays (et non l'IRI).
expect(refs.countries.value).toEqual([{ value: 'France', label: 'France' }])
// Seul le select en echec reste vide.
expect(refs.categories.value).toEqual([])
@@ -3,7 +3,7 @@ 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.
* reglement, banques, pays, 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
@@ -57,6 +57,11 @@ interface ClientMember extends HydraMember {
companyName: string
}
interface CountryMember extends HydraMember {
code: string
name: string
}
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
export function useClientReferentials() {
@@ -68,6 +73,7 @@ export function useClientReferentials() {
const paymentDelays = ref<RefOption[]>([])
const paymentTypes = ref<PaymentTypeOption[]>([])
const banks = ref<RefOption[]>([])
const countries = ref<RefOption[]>([])
const distributors = ref<ClientOption[]>([])
const brokers = ref<ClientOption[]>([])
@@ -116,6 +122,12 @@ export function useClientReferentials() {
.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 })) }),
// Pays (ERP-116) : la valeur d'option est le NOM du pays (et non l'IRI),
// car l'adresse stocke `country` en chaine libre (« France »...). On
// conserve ainsi la compatibilite avec les adresses existantes sans FK
// ni migration de donnees a ce stade. value === label.
fetchAll<CountryMember>('/countries')
.then((list) => { countries.value = list.map(c => ({ value: c.name, label: c.name })) }),
])
}
@@ -144,6 +156,7 @@ export function useClientReferentials() {
paymentDelays,
paymentTypes,
banks,
countries,
distributors,
brokers,
loadCommon,
@@ -51,6 +51,11 @@ interface ReferentialMember extends HydraMember {
label: string
}
interface CountryMember extends HydraMember {
code: string
name: string
}
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
export function useSupplierReferentials() {
@@ -62,6 +67,7 @@ export function useSupplierReferentials() {
const paymentDelays = ref<RefOption[]>([])
const paymentTypes = ref<PaymentTypeOption[]>([])
const banks = ref<RefOption[]>([])
const countries = ref<RefOption[]>([])
/** Recupere une collection complete (pagination desactivee) en Hydra. */
async function fetchAll<T extends HydraMember>(
@@ -103,6 +109,13 @@ export function useSupplierReferentials() {
.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 })) }),
// Pays (ERP-116) : la valeur d'option est le NOM du pays (et non l'IRI),
// car l'adresse stocke `country` en chaine libre (« France »...). On
// conserve ainsi la compatibilite avec les adresses existantes sans FK
// ni migration de donnees a ce stade. value === label. Aligne sur les
// clients (`useClientReferentials`) pour une liste de pays identique.
fetchAll<CountryMember>('/countries')
.then((list) => { countries.value = list.map(c => ({ value: c.name, label: c.name })) }),
])
}
@@ -113,6 +126,7 @@ export function useSupplierReferentials() {
paymentDelays,
paymentTypes,
banks,
countries,
loadCommon,
}
}
@@ -553,10 +553,21 @@ const contactOptions = computed<RefOption[]>(() =>
})),
)
const countryOptions: RefOption[] = [
{ value: 'France', label: 'France' },
{ value: 'Espagne', label: 'Espagne' },
]
// Pays : referentiel `country` charge via l'API (ERP-116), en remplacement de
// l'ancienne liste codee en dur. Valeur = nom du pays (l'adresse stocke
// `country` en chaine libre, donc value === label). On merge la valeur deja
// stockee sur chaque adresse (embed) — comme les autres selects de cet ecran —
// pour ne pas vider le select si `/countries` echoue (resilience ERP-102) ou si
// un pays historique n'appartient pas au referentiel.
const embedCountryOptions = computed<RefOption[]>(() =>
mergeOptions([], (client.value?.addresses ?? [])
.map(a => a.country)
.filter((c): c is string => !!c)
.map(c => ({ value: c, label: c }))),
)
const countryOptions = computed<RefOption[]>(() =>
mergeOptions(referentials.countries.value, embedCountryOptions.value),
)
const relationOptions = computed<RefOption[]>(() => [
{ value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') },
@@ -384,10 +384,18 @@ const relationOptions = computed<SelectOption[]>(() => [
{ value: 'courtier', label: t('commercial.clients.form.main.relationBroker') },
])
const countryOptions: SelectOption[] = [
{ value: 'France', label: 'France' },
{ value: 'Espagne', label: 'Espagne' },
]
// Pays (ERP-116) : options construites depuis l'EMBED des adresses (jamais via
// GET /countries, sur le meme principe que les autres selects de consultation
// — en 403 pour les roles metier non-admin). Valeur = nom du pays stocke tel
// quel dans l'adresse, donc value === label ; suffit a afficher le libelle en
// lecture seule.
const countryOptions = computed<SelectOption[]>(() =>
[...new Set(
(client.value?.addresses ?? [])
.map(a => a.country)
.filter((c): c is string => !!c),
)].map(c => ({ value: c, label: c })),
)
// Selects comptables : libelle issu de l'embed (option unique ou vide).
const tvaModeOptions = computed(() => referentialOptionOf(client.value?.tvaMode))
@@ -778,11 +778,17 @@ const contactOptions = computed<RefOption[]>(() =>
})),
)
// Pays disponibles (France preselectionnee par defaut sur chaque adresse).
const countryOptions: RefOption[] = [
{ value: 'France', label: 'France' },
{ value: 'Espagne', label: 'Espagne' },
]
// Pays disponibles : referentiel `country` charge via l'API (ERP-116), en
// remplacement de l'ancienne liste codee en dur. France reste preselectionnee
// par defaut sur chaque adresse (cf. valeur initiale du draft d'adresse) : on
// garantit donc sa presence en fallback si `/countries` echoue (resilience
// ERP-102), pour ne pas afficher un select vide sur une valeur deja soumise.
const countryOptions = computed<RefOption[]>(() => {
const list = referentials.countries.value
return list.some(c => c.value === 'France')
? list
: [{ value: 'France', label: 'France' }, ...list]
})
// « + Adresse » desactive tant que la derniere adresse n'est pas valide.
const canAddAddress = computed(() => {
@@ -515,10 +515,19 @@ const contactOptions = computed<RefOption[]>(() =>
})),
)
const countryOptions: RefOption[] = [
{ value: 'France', label: 'France' },
{ value: 'Espagne', label: 'Espagne' },
]
// Pays : referentiel `country` charge via l'API (ERP-116), aligne sur l'ecran
// client. On merge la valeur deja stockee sur chaque adresse (embed) — comme les
// autres selects de cet ecran — pour ne pas vider le select si `/countries`
// echoue (resilience ERP-102) ou si un pays historique n'est plus au referentiel.
const embedCountryOptions = computed<RefOption[]>(() =>
mergeOptions([], (supplier.value?.addresses ?? [])
.map(a => a.country)
.filter((c): c is string => !!c)
.map(c => ({ value: c, label: c }))),
)
const countryOptions = computed<RefOption[]>(() =>
mergeOptions(referentials.countries.value, embedCountryOptions.value),
)
// Selects comptables : referentiel UNION valeur courante de l'embed (libelle).
const tvaModeOptions = computed(() => mergeOptions(referentials.tvaModes.value, referentialOptionOf(supplier.value?.tvaMode)))
@@ -361,10 +361,16 @@ const allSiteOptions = computed<SelectOption[]>(() =>
})),
)
const countryOptions: SelectOption[] = [
{ value: 'France', label: 'France' },
{ value: 'Espagne', label: 'Espagne' },
]
// Pays (consultation, lecture seule) : derive des adresses du fournisseur, comme
// l'ecran client. Le referentiel `country` (ERP-116) n'est pas charge ici, l'ecran
// n'affiche que les valeurs deja stockees.
const countryOptions = computed<SelectOption[]>(() =>
[...new Set(
(supplier.value?.addresses ?? [])
.map(a => a.country)
.filter((c): c is string => !!c),
)].map(c => ({ value: c, label: c })),
)
// Selects comptables : libelle issu de l'embed (option unique ou vide).
const tvaModeOptions = computed(() => referentialOptionOf(supplier.value?.tvaMode))
@@ -646,11 +646,15 @@ const contactOptions = computed<RefOption[]>(() =>
})),
)
// Pays disponibles (France preselectionnee par defaut sur chaque adresse).
const countryOptions: RefOption[] = [
{ value: 'France', label: 'France' },
{ value: 'Espagne', label: 'Espagne' },
]
// Pays : referentiel `country` charge via l'API (ERP-116), aligne sur l'ecran
// client. France garantie en tete pour rester preselectionnable par defaut sur
// chaque adresse meme si `/countries` echoue (resilience ERP-102).
const countryOptions = computed<RefOption[]>(() => {
const list = referentials.countries.value
return list.some(c => c.value === 'France')
? list
: [{ value: 'France', label: 'France' }, ...list]
})
// « + Adresse » desactive tant que la derniere adresse n'est pas valide.
const canAddAddress = computed(() => {