feat(catalog) : M6 — écran Modification produit + onglets placeholder (ERP-206)

Écran de modification (ajout pré-rempli, bouton « Enregistrer ») et pose des
onglets Fournisseurs/Clients en placeholder « en cours de développement ».

- route /admin/products/{id}/edit : useProduct(id) charge le détail, prefill du formulaire principal
- RG-6.08 : useProductForm en mode édition → PATCH /products/{id} (merge-patch), bouton « Enregistrer »
- unicité du code re-validée serveur en édition (409 doublon mappé inline)
- onglets Fournisseurs + Clients : ComingSoonPlaceholder, aucun appel API ni champ (HP-M6-01 / RG-6.10)
- mêmes onglets placeholder posés sur l'écran Ajouter (cohérence)
- i18n admin.products.edit / tab ; 11 tests Vitest (prefill + PATCH + placeholder)
This commit is contained in:
2026-06-25 18:01:33 +02:00
parent ce0e274743
commit 64c3b9b6ec
10 changed files with 544 additions and 10 deletions
+13 -1
View File
@@ -1061,10 +1061,22 @@
"containsMolasses": "Contient de la mélasse",
"duplicateCode": "Un produit portant ce code existe déjà."
},
"edit": {
"title": "Modifier le produit",
"back": "Retour au catalogue",
"save": "Enregistrer",
"loading": "Chargement du produit…",
"notFound": "Produit introuvable."
},
"tab": {
"suppliers": "Fournisseurs",
"clients": "Clients"
},
"toast": {
"error": "Une erreur est survenue. Réessayez.",
"exportError": "L'export du catalogue produit a échoué. Réessayez.",
"createSuccess": "Produit créé avec succès"
"createSuccess": "Produit créé avec succès",
"updateSuccess": "Produit mis à jour avec succès"
}
}
}
@@ -0,0 +1,27 @@
<template>
<!--
Onglets « Fournisseurs » / « Clients » de la fiche produit HORS PERIMETRE
V0 (HP-M6-01, RG-6.10) : ils dependent d'un module Contrat inexistant.
Rendu en placeholder « en cours de développement » (meme composant que les
onglets non-dev des fiches M1→M4). AUCUN appel API, AUCUN champ saisissable.
Affiches sans condition d'etat (a raffiner avec le module Contrat).
-->
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<template #suppliers><ComingSoonPlaceholder /></template>
<template #clients><ComingSoonPlaceholder /></template>
</MalioTabList>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
const { t } = useI18n()
const activeTab = ref('suppliers')
// Icone (Iconify) par onglet, alignee sur la convention des fiches existantes.
const tabs = computed(() => [
{ key: 'suppliers', label: t('admin.products.tab.suppliers'), icon: 'mdi:truck-outline' },
{ key: 'clients', label: t('admin.products.tab.clients'), icon: 'mdi:account-group-outline' },
])
</script>
@@ -6,6 +6,7 @@ import { useProductForm } from '../useProductForm'
// Stubs des auto-imports Nuxt consommes par le composable + ses dependances.
const mockGet = vi.hoisted(() => vi.fn())
const mockPost = vi.hoisted(() => vi.fn())
const mockPatch = vi.hoisted(() => vi.fn())
const mockToastSuccess = vi.hoisted(() => vi.fn())
const mockToastError = vi.hoisted(() => vi.fn())
@@ -13,7 +14,7 @@ vi.stubGlobal('useApi', () => ({
get: mockGet,
post: mockPost,
put: vi.fn(),
patch: vi.fn(),
patch: mockPatch,
delete: vi.fn(),
}))
vi.stubGlobal('useToast', () => ({
@@ -51,6 +52,7 @@ describe('useProductForm', () => {
beforeEach(() => {
mockGet.mockReset()
mockPost.mockReset()
mockPatch.mockReset()
mockToastSuccess.mockReset()
mockToastError.mockReset()
@@ -217,4 +219,69 @@ describe('useProductForm', () => {
expect(mockToastError).not.toHaveBeenCalled()
})
})
describe('RG-6.08 — mode edition (prefill + PATCH)', () => {
// Produit charge (memes cles que la reponse reelle § 4.0.bis : @id sur les relations).
const PRODUCT = {
id: 34,
code: 'BLE-01',
name: 'Blé tendre',
states: ['PURCHASE', 'SALE'],
manufactured: true,
containsMolasses: false,
category: { '@id': '/api/categories/12', id: 12, name: 'Céréales', code: 'CEREALES' },
sites: [{ '@id': '/api/sites/1', id: 1, name: 'Chatellerault', code: '86', postalCode: '86100', city: 'C', color: '#000', fullAddress: 'x' }],
storageTypes: [{ '@id': '/api/storage_types/9', id: 9, code: 'TAS', label: 'Tas' }],
createdAt: '', updatedAt: '',
}
it('pre-remplit le formulaire depuis le produit (relations en IRI) + charge le stockage', async () => {
const { form, prefill, storageTypeOptions } = useProductForm()
await prefill(PRODUCT)
expect(form.code).toBe('BLE-01')
expect(form.name).toBe('Blé tendre')
expect(form.states).toEqual(['PURCHASE', 'SALE'])
expect(form.categoryIri).toBe('/api/categories/12')
expect(form.siteIris).toEqual(['/api/sites/1'])
expect(form.storageTypeIris).toEqual(['/api/storage_types/9'])
expect(form.manufactured).toBe(true)
// Cascade : options de stockage chargees pour le site du produit.
expect(mockGet).toHaveBeenCalledWith(
'/storage_types',
expect.objectContaining({ 'siteId[]': ['1'] }),
expect.any(Object),
)
expect(storageTypeOptions.value.map(o => o.value)).toContain('/api/storage_types/9')
})
it('soumet un PATCH /products/{id} apres prefill (RG-6.08)', async () => {
mockPatch.mockResolvedValueOnce({ id: 34 })
const { prefill, submit } = useProductForm()
await prefill(PRODUCT)
const ok = await submit()
expect(ok).toBe(true)
expect(mockPost).not.toHaveBeenCalled()
expect(mockPatch).toHaveBeenCalledWith(
'/products/34',
expect.objectContaining({ code: 'BLE-01', name: 'Blé tendre' }),
expect.objectContaining({ toast: false }),
)
expect(mockToastSuccess).toHaveBeenCalled()
})
it('mappe un 409 doublon de code aussi en edition', async () => {
mockPatch.mockRejectedValueOnce({ response: { status: 409, _data: {} } })
const { errors, prefill, submit } = useProductForm()
await prefill(PRODUCT)
const ok = await submit()
expect(ok).toBe(false)
expect(errors.code).toBe('admin.products.form.duplicateCode')
expect(mockToastError).toHaveBeenCalled()
})
})
})
@@ -0,0 +1,41 @@
import { ref } from 'vue'
import type { Product } from '~/modules/catalog/types/product'
/**
* Chargement d'un produit unique (ecran « Modification produit », M6 — ERP-206).
* Lit le detail via `GET /api/products/{id}` — meme structure que la ligne de
* liste (category / sites / storageTypes embarques, § 4.0.bis).
*
* L'en-tete `Accept: application/ld+json` est impose pour obtenir le payload
* Hydra complet (IRI `@id` des relations, necessaires au pre-remplissage des
* selects). Etat 100 % local a l'instance (refs) — aucune persistance URL.
*/
export function useProduct(id: number | string) {
const api = useApi()
const product = ref<Product | null>(null)
const loading = ref(false)
const error = ref(false)
/** Charge le detail du produit. En cas d'echec : `error = true`, `product = null`. */
async function load(): Promise<void> {
loading.value = true
error.value = false
try {
product.value = await api.get<Product>(
`/products/${id}`,
{},
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
}
catch {
error.value = true
product.value = null
}
finally {
loading.value = false
}
}
return { product, loading, error, load }
}
@@ -15,6 +15,7 @@ import {
useCategoryOptions,
useStorageTypeOptions,
} from '~/modules/catalog/composables/useProductOptions'
import type { Product } from '~/modules/catalog/types/product'
/** Etats produit (miroir de l'enum back Product::STATE_*). */
export const PRODUCT_STATES = ['PURCHASE', 'SALE', 'OTHER'] as const
@@ -51,6 +52,10 @@ export function useProductForm() {
const submitting = ref(false)
// Id du produit edite (null = creation). Pilote l'URL/methode du submit (RG-6.08 :
// « Modification » = meme formulaire/regles que « Ajouter », bouton « Enregistrer »).
const productId = ref<number | null>(null)
// RG-6.03 : « Fabriqué » / « Contient de la mélasse » saisissables uniquement
// si l'etat contient « Vendu » (SALE).
const isSale = computed(() => form.states.includes('SALE'))
@@ -104,9 +109,34 @@ export function useProductForm() {
}
/**
* Soumet la creation. Retourne true au succes (la page redirige), false sinon.
* 422 → mapping inline par champ (useFormErrors) ; 409 doublon de code →
* erreur inline sur `code` + toast explicite (RG-6.01).
* Pre-remplit le formulaire depuis un produit charge (mode edition, RG-6.08).
* Les relations sont reprises via leur IRI `@id` (= valeur d'option des selects).
* Charge au passage les options de Type de stockage pour les sites du produit,
* afin que le multi-select affiche les libelles et conserve la selection.
*/
async function prefill(product: Product): Promise<void> {
productId.value = product.id
form.code = product.code
form.name = product.name
form.states = [...product.states]
form.categoryIri = product.category?.['@id'] ?? null
form.siteIris = product.sites.map(s => s['@id'])
form.manufactured = product.manufactured
form.containsMolasses = product.containsMolasses
const siteIds = form.siteIris
.map(iriToId)
.filter((id): id is number => id !== null)
await storage.load(siteIds)
form.storageTypeIris = product.storageTypes.map(st => st['@id'])
}
/**
* Soumet le formulaire. Retourne true au succes (la page redirige), false sinon.
* Creation → `POST /products` ; edition (productId non nul, RG-6.08) →
* `PATCH /products/{id}` (mode merge-patch gere par useApi). 422 → mapping
* inline par champ (useFormErrors) ; 409 doublon de code → erreur inline sur
* `code` + toast explicite (RG-6.01, unicite re-validee aussi en edition).
*/
async function submit(): Promise<boolean> {
if (submitting.value) {
@@ -114,6 +144,7 @@ export function useProductForm() {
}
submitting.value = true
formErrors.clearErrors()
const editing = productId.value !== null
try {
const payload = {
code: form.code || null,
@@ -127,11 +158,15 @@ export function useProductForm() {
sites: form.siteIris,
storageTypes: form.storageTypeIris,
}
await api.post('/products', payload, {
headers: { Accept: 'application/ld+json' },
toast: false,
})
toast.success({ title: t('admin.products.toast.createSuccess') })
const options = { headers: { Accept: 'application/ld+json' }, toast: false }
if (editing) {
await api.patch(`/products/${productId.value}`, payload, options)
toast.success({ title: t('admin.products.toast.updateSuccess') })
}
else {
await api.post('/products', payload, options)
toast.success({ title: t('admin.products.toast.createSuccess') })
}
return true
}
catch (error) {
@@ -154,6 +189,7 @@ export function useProductForm() {
return {
form,
productId,
errors: formErrors.errors,
submitting,
isSale,
@@ -165,6 +201,7 @@ export function useProductForm() {
setStorageTypes,
setSites,
loadReferentials,
prefill,
submit,
}
}
@@ -0,0 +1,154 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, h, Suspense } from 'vue'
// Produit charge simule (cles de la reponse reelle § 4.0.bis).
const PRODUCT = {
id: 34,
code: 'BLE-01',
name: 'Blé tendre',
states: ['PURCHASE'],
manufactured: false,
containsMolasses: false,
category: { '@id': '/api/categories/12', id: 12, name: 'Céréales', code: 'CEREALES' },
sites: [{ '@id': '/api/sites/1', id: 1, name: 'Chatellerault' }],
storageTypes: [{ '@id': '/api/storage_types/9', id: 9, code: 'TAS', label: 'Tas' }],
createdAt: '', updatedAt: '',
}
// Holders crees dans les factories (vue initialise au moment de l'import page).
const fx = vi.hoisted(() => ({
isSale: null as unknown as { value: boolean },
submit: vi.fn(),
prefill: vi.fn(),
loadReferentials: vi.fn(),
load: vi.fn(),
}))
vi.mock('~/modules/catalog/composables/useProductForm', async () => {
const { ref, reactive } = await import('vue')
fx.isSale = ref(false)
return {
PRODUCT_STATES: ['PURCHASE', 'SALE', 'OTHER'],
useProductForm: () => ({
form: reactive({
code: null, name: null, states: [], siteIris: [],
categoryIri: null, storageTypeIris: [], manufactured: false, containsMolasses: false,
}),
errors: reactive({}),
submitting: ref(false),
isSale: fx.isSale,
siteOptions: ref([]),
categoryOptions: ref([]),
storageTypeOptions: ref([]),
setStates: vi.fn(),
setCategory: vi.fn(),
setStorageTypes: vi.fn(),
setSites: vi.fn(),
loadReferentials: fx.loadReferentials,
prefill: fx.prefill,
submit: fx.submit,
}),
}
})
vi.mock('~/modules/catalog/composables/useProduct', async () => {
const { ref } = await import('vue')
return {
useProduct: () => ({
product: ref(PRODUCT),
loading: ref(false),
error: ref(false),
load: fx.load,
}),
}
})
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
const mockPush = vi.hoisted(() => vi.fn())
const mockNavigateTo = vi.hoisted(() => vi.fn())
const mockCan = vi.hoisted(() => vi.fn())
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('useHead', () => undefined)
vi.stubGlobal('useRoute', () => ({ params: { id: '34' } }))
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
vi.stubGlobal('navigateTo', mockNavigateTo)
const EditPage = (await import('../admin/products/[id]/edit.vue')).default
const ButtonStub = defineComponent({
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
emits: ['click'],
setup(props, { emit }) {
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
},
})
const InputStub = defineComponent({
props: { label: { type: String, default: '' }, modelValue: { default: null } },
setup(props) { return () => h('input', { 'data-label': props.label }) },
})
const CheckboxStub = defineComponent({
props: { label: { type: String, default: '' } },
setup(props) { return () => h('input', { 'type': 'checkbox', 'data-label': props.label }) },
})
// Placeholder : rendu sans aucun appel API (juste un marqueur).
const TabsStub = defineComponent({ setup() { return () => h('div', { 'data-testid': 'placeholder-tabs' }) } })
const stubs = {
MalioButtonIcon: ButtonStub,
MalioButton: ButtonStub,
MalioInputText: InputStub,
MalioSelect: InputStub,
MalioSelectCheckbox: InputStub,
MalioCheckbox: CheckboxStub,
ProductPlaceholderTabs: TabsStub,
}
async function mountPage() {
const wrapper = mount(defineComponent({
components: { EditPage },
setup: () => () => h(Suspense, null, { default: () => h(EditPage) }),
}), { global: { stubs } })
await flushPromises()
return wrapper
}
describe('Écran Modifier un produit (page /admin/products/{id}/edit)', () => {
beforeEach(() => {
fx.submit.mockReset().mockResolvedValue(true)
fx.prefill.mockReset().mockResolvedValue(undefined)
fx.loadReferentials.mockReset().mockResolvedValue(undefined)
fx.load.mockReset().mockResolvedValue(undefined)
mockPush.mockReset()
mockNavigateTo.mockReset()
mockCan.mockReset().mockReturnValue(true)
fx.isSale.value = false
})
it('charge le produit et pre-remplit le formulaire au montage', async () => {
await mountPage()
expect(fx.load).toHaveBeenCalled()
expect(fx.prefill).toHaveBeenCalledWith(PRODUCT)
})
it('redirige vers la liste sans la permission manage', async () => {
mockCan.mockReturnValue(false)
await mountPage()
expect(mockNavigateTo).toHaveBeenCalledWith('/admin/products')
})
it('bouton « Enregistrer » : submit (PATCH) puis retour a la liste', async () => {
const wrapper = await mountPage()
await wrapper.find('[data-label="admin.products.edit.save"]').trigger('click')
await flushPromises()
expect(fx.submit).toHaveBeenCalled()
expect(mockPush).toHaveBeenCalledWith('/admin/products')
})
it('affiche les onglets placeholder (rendu sans appel API)', async () => {
const wrapper = await mountPage()
expect(wrapper.find('[data-testid="placeholder-tabs"]').exists()).toBe(true)
})
})
@@ -67,6 +67,9 @@ const CheckboxStub = defineComponent({
setup(props) { return () => h('input', { 'type': 'checkbox', 'data-label': props.label }) },
})
// Placeholder (onglets Fournisseurs/Clients) : marqueur sans appel API.
const TabsStub = defineComponent({ setup() { return () => h('div', { 'data-testid': 'placeholder-tabs' }) } })
const stubs = {
MalioButtonIcon: ButtonStub,
MalioButton: ButtonStub,
@@ -74,6 +77,7 @@ const stubs = {
MalioSelect: InputStub,
MalioSelectCheckbox: InputStub,
MalioCheckbox: CheckboxStub,
ProductPlaceholderTabs: TabsStub,
}
async function mountPage() {
@@ -0,0 +1,182 @@
<template>
<div>
<!-- En-tete : retour vers le catalogue + nom du produit. -->
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
:title="t('admin.products.edit.back')"
v-bind="{ ariaLabel: t('admin.products.edit.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
</div>
<!-- Etats de chargement / introuvable. -->
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('admin.products.edit.loading') }}</p>
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('admin.products.edit.notFound') }}</p>
<template v-else-if="product">
<!-- Formulaire principal pre-rempli (mêmes champs/regles que l'ajout,
RG-6.016.07). Bouton « Enregistrer » PATCH (RG-6.08). -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<!-- Etat du produit : multi-select obligatoire (>= 1, RG-6.02). -->
<MalioSelectCheckbox
:model-value="form.states"
:options="stateOptions"
:label="t('admin.products.form.states')"
:display-tag="true"
:required="true"
:error="errors.states"
@update:model-value="(v: (string | number)[]) => setStates(v.map(String))"
/>
<!-- Sites de disponibilite : multi-select obligatoire (>= 1, RG-6.04).
Pilote la cascade Type de stockage (RG-6.06). -->
<MalioSelectCheckbox
:model-value="form.siteIris"
:options="siteOptions"
:label="t('admin.products.form.sites')"
:display-tag="true"
:required="true"
:error="errors.sites"
@update:model-value="(v: (string | number)[]) => setSites(v.map(String))"
/>
<MalioInputText
v-model="form.name"
:mask="FREE_TEXT_MASK"
:label="t('admin.products.form.name')"
:required="true"
:error="errors.name"
/>
<!-- Code modifiable techniquement ; l'unicite reste re-validee serveur (RG-6.01). -->
<MalioInputText
v-model="form.code"
:mask="CODE_ALNUM_MASK"
:label="t('admin.products.form.code')"
:required="true"
:error="errors.code"
/>
<!-- Categorie produit : select simple obligatoire, filtre type PRODUIT (RG-6.05). -->
<MalioSelect
:model-value="form.categoryIri"
:options="categoryOptions"
:label="t('admin.products.form.category')"
empty-option-label=""
:required="true"
:error="errors.category"
@update:model-value="(v: string | number | null) => setCategory(v === null || v === '' ? null : String(v))"
/>
<!-- Type de stockage : multi-select obligatoire (>= 1), options filtrees
par les sites selectionnes (RG-6.06). -->
<MalioSelectCheckbox
:model-value="form.storageTypeIris"
:options="storageTypeOptions"
:label="t('admin.products.form.storageTypes')"
:display-tag="true"
:required="true"
:error="errors.storageTypes"
@update:model-value="(v: (string | number)[]) => setStorageTypes(v.map(String))"
/>
<!-- RG-6.03 : « Fabriqué » + « Contient de la mélasse » visibles
uniquement si l'Etat contient « Vendu ». -->
<MalioCheckbox
v-if="isSale"
v-model="form.manufactured"
:label="t('admin.products.form.manufactured')"
group-class="self-center"
/>
<MalioCheckbox
v-if="isSale"
v-model="form.containsMolasses"
:label="t('admin.products.form.containsMolasses')"
group-class="self-center"
/>
</div>
<div class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('admin.products.edit.save')"
:disabled="submitting"
@click="onSubmit"
/>
</div>
<!-- Onglets Fournisseurs / Clients en placeholder (HP-M6-01). -->
<ProductPlaceholderTabs />
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useProductForm, PRODUCT_STATES } from '~/modules/catalog/composables/useProductForm'
import { useProduct } from '~/modules/catalog/composables/useProduct'
import { CODE_ALNUM_MASK, FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const { can } = usePermissions()
// Gating de la route : la modification est reservee a `manage` (catalogue admin-only).
if (!can('catalog.products.manage')) {
await navigateTo('/admin/products')
}
const productId = route.params.id as string
const { product, loading, error, load } = useProduct(productId)
const {
form,
errors,
submitting,
isSale,
siteOptions,
categoryOptions,
storageTypeOptions,
setStates,
setCategory,
setStorageTypes,
setSites,
loadReferentials,
prefill,
submit,
} = useProductForm()
const headerTitle = computed(() => product.value?.name ?? t('admin.products.edit.title'))
useHead({ title: headerTitle })
// Options de l'etat : libelles i18n (la valeur d'option = code enum).
const stateOptions = computed(() =>
PRODUCT_STATES.map(code => ({ value: code, label: t(`admin.products.state.${code}`) })),
)
/** Retour vers le catalogue produit (fleche d'en-tete). */
function goBack(): void {
router.push('/admin/products')
}
/** Soumet la modification (PATCH) ; au succes, retour a la liste. */
async function onSubmit(): Promise<void> {
const ok = await submit()
if (ok) {
router.push('/admin/products')
}
}
onMounted(async () => {
// Referentiels (selects) + detail du produit charges en parallele.
await Promise.all([
loadReferentials().catch(() => {}),
load(),
])
// Pre-remplissage une fois le produit charge (echec de chargement => message).
if (product.value) {
await prefill(product.value)
}
})
</script>
@@ -97,6 +97,10 @@
@click="onSubmit"
/>
</div>
<!-- Onglets Fournisseurs / Clients en placeholder (HP-M6-01) : presents
des l'ajout pour coherence avec l'ecran de modification. -->
<ProductPlaceholderTabs />
</div>
</template>
@@ -22,6 +22,8 @@ export interface ProductCategoryType {
/** Categorie embarquee dans un produit (lecture seule, sous-ensemble utile au front). */
export interface ProductCategory {
/** IRI Hydra, ex. `/api/categories/12` — utilise pour pre-selectionner le select en edition. */
'@id': string
id: number
name: string
code: string
@@ -30,6 +32,8 @@ export interface ProductCategory {
/** Site de disponibilite embarque dans un produit (groupe `site:read`). */
export interface ProductSite {
/** IRI Hydra, ex. `/api/sites/1` — utilise pour pre-selectionner le multi-select en edition. */
'@id': string
id: number
name: string
code: string
@@ -41,6 +45,8 @@ export interface ProductSite {
/** Type de stockage embarque dans un produit (referentiel borne, § 2.4). */
export interface ProductStorageType {
/** IRI Hydra, ex. `/api/storage_types/9` — utilise pour pre-selectionner le multi-select en edition. */
'@id': string
id: number
code: string
label: string