# AUDIT ESTAIR Connect — Bug Tracking & Refactoring Plan

**Périmètre** : Frontend Nuxt + ESTAIR backend (Laravel) + CHAPS backend (Laravel) + bases de données associées
**Issu de** : analyse statique du code et des dumps SQL (`estair.sql`, `estair_bo.sql`)
**Date** : 09 mai 2026
**Destinataire** : Claude Code, traitement file-by-file

---

## Préambule — décisions architecturales validées

Ces décisions structurantes guident TOUS les patches du document. À ne pas remettre en cause au cas par cas.

### D1 — Rôles canoniques, sans mapping

Un seul jeu de rôles utilisé partout (Keycloak, CHAPS DB, ESTAIR backend, frontend) :

```
admin, support, sale, finance, agency, agent, viewer
```

Les anciens rôles (`super_admin`, `admin_agence`, `manager`, `comptable`) **doivent être migrés**, pas mappés. Tout code qui contient un mapping de rétrocompatibilité doit être supprimé.

Keycloak sera mis à jour manuellement par Nicephore en parallèle de cet audit.

### D2 — CHAPS = source unique de vérité côté frontend

Le frontend ne consulte **rien d'autre** que CHAPS pour décider :

| Question | Source |
|---|---|
| L'utilisateur peut-il voir ce module ? | CHAPS `menus` (filtré par rôle, déjà côté serveur) |
| L'utilisateur peut-il créer/modifier/supprimer ? | CHAPS `cta` par view |
| Quels boutons afficher dans la toolbar ? | CHAPS `cta` par view |
| Cette ligne est-elle éligible à cette action métier ? | CHAPS `cta` par view (DetailView) |

**Pas de fallback "tout permis"** : si CHAPS ne retourne aucune CTA pour une vue, aucun bouton n'apparaît.

La matrice CASL hardcodée côté frontend (`composables/usePermissions.ts`) est supprimée. Le composable disparaît.

### D3 — Pipeline B uniquement

Trois pipelines existaient :
- A : `data.permissions` (booléens 9 actions)
- B : `*.cta` par view
- C : `availableActions` (état-machine ESTAIR)

**Seul le Pipeline B est conservé** pour les décisions UI. Pipeline A est dérivable depuis B (présence d'une CTA `create_list` ⇔ permission `create`). Pipeline C est replié dans B en étendant `ctas_enum` (cf. tâche T2).

### D4 — Dispatch ESTAIR → CHAPS idempotent et delta

Le dispatch fait par ESTAIR doit être un **upsert delta** :
- ajoute les nouvelles colonnes en metadatas/picklists
- supprime celles qui n'existent plus en DB ESTAIR
- **préserve** les modifications faites manuellement dans CHAPS Admin (libellés, blocs, séquences, list/create/update/detail flags, header)

Un scheduler quotidien rejoue le dispatch automatiquement.

### D5 — Convention picklist confirmée

```
picklists.name  = identifiant machine composite : {module}_{field}_{value}  (jamais affiché)
picklists.field = nom de la colonne dans la table de données
picklists.value = valeur littérale stockée par ESTAIR ET label affiché par défaut
                  (sauf si i18n dédiée)
colors.color_back / colors.color_front = couleurs hex appliquées au chip
```

Le matching frontend se fait sur `option.value === record.field`. Tout autre matching est supprimé.

### D6 — Convention icônes

Les icônes en base CHAPS portent toutes le préfixe `icon-*` (legacy). Le frontend Vuexy utilise `tabler-*`. **Décision** : mapping côté frontend via un helper `mapIcon()` (cf. F-05). Pas de migration DB pour l'instant — décorrélé.

---

## Comment lire ce document

Chaque entrée suit ce format :

```
### [ID] — [Titre court]
**Sévérité** : Critical / High / Medium / Low
**Fichier** : chemin/fichier.ext
**Lignes** : N–M
**Symptôme** : ce que l'utilisateur final observe
**Cause racine** : pourquoi ça arrive techniquement
**Fix** : code corrigé (souvent diff partiel)
**Test** : comment vérifier que c'est corrigé
**Dépendance** : autres items à appliquer avant celui-ci
```

Les patches sont **prêts à coller**. Quand un fichier nécessite plusieurs changements, ils sont regroupés dans une seule entrée.

---

## Matrice de priorisation

| ID | Sévérité | Catégorie | Effort | Bloque |
|---|---|---|---|---|
| F-01 | Critical | Frontend | M | F-03, F-04, F-11 |
| F-02 | Critical | Frontend | S | F-03, F-11 |
| F-03 | Critical | Frontend | M | — |
| F-04 | High | Frontend | S | F-01 |
| F-05 | High | Frontend | S | F-01 |
| F-06 | Low | Frontend | XS | — |
| F-07 | Low | Frontend | XS | — |
| F-08 | Medium | Frontend | S | — |
| F-09 | Medium | Frontend | XS | — |
| F-10 | Low | Frontend | XS | — |
| F-11 | High | Frontend | S | F-01, F-02 |
| F-12 | High | Frontend | M | D1 (Keycloak) |
| F-13 | Medium | Frontend | S | F-11 |
| F-14 | Low | Frontend | XS | — |
| F-15 | Medium | Frontend | S | F-04 |
| E-01 | Critical | ESTAIR | M | T2 |
| E-02 | Medium | ESTAIR | XS | — |
| E-03 | Medium | ESTAIR | XS | — |
| E-04 | High | ESTAIR | L | D4 |
| **E-05** | **Critical** | **ESTAIR** | **S** | **— (P0 ABSOLUE)** |
| **E-06** | **Critical** | **ESTAIR** | **S** | **— (P0 ABSOLUE)** |
| **E-07** | **Medium** | **ESTAIR + Frontend** | **XS** | **—** |
| **E-08** | **Medium** | **ESTAIR** | **S** | **E-06** |
| **E-09** | **Medium** | **ESTAIR** | **S** | **E-06** |
| C-01 | High | CHAPS | XS | — |
| C-02 | Medium | CHAPS | S | — |
| C-03 | Medium | CHAPS | XS | — |
| C-04 | Medium | CHAPS | XS | — |
| C-05 | High | CHAPS | M | T2, T3 |
| D-01 | Critical | CHAPS DB | S | D1, T1 |
| D-02 | High | CHAPS DB | M | T2 |

Effort: XS = <1h, S = 1-3h, M = 3-8h, L = 1-2j

---

# 1. Frontend Nuxt

## F-01 — `useMetadata` ne peuple jamais les CTAs

**Sévérité** : Critical
**Fichier** : `composables/useMetadata.ts`
**Lignes** : 237 (déclaration) + absence dans `fetchMetadata`

**Symptôme** : aucun bouton CTA configuré dans CHAPS Admin n'apparaît dans le frontend.

**Cause racine** : `ctas` est déclaré comme `ref<CTA[]>([])` mais jamais mis à jour depuis la réponse CHAPS qui contient pourtant `CreateView.cta`, `UpdateView.cta`, `DetailView.cta`, `ListeView.cta`.

**Code actuel (extrait pertinent)** :
```ts
async function fetchMetadata() {
  // ...
  const data = await $api<any>(`/chaps/v1/metadata/${name}`)
  const listeView  = data?.ListeView  ?? data?.listeView
  const createView = data?.CreateView ?? data?.createView
  const updateView = data?.UpdateView ?? data?.updateView
  const detailView = data?.DetailView ?? data?.detailView
  // ... parsing fields ...
  const parsed: ModuleMetadata = {
    /* ...fields... */
  }
  metadata.value = parsed
}

const ctas = ref<CTA[]>([])  // ligne 237 — jamais peuplé
```

**Fix** :

```ts
import type { CTA, ChapsView, ModuleMetadata } from '@/types/chaps'

// À ajouter au début du composable (après les imports)
function parseCtas(view: any): CTA[] {
  const arr = view?.cta ?? view?.ctas ?? []
  if (!Array.isArray(arr)) return []
  return arr
    .filter((c: any) => c.is_active === 1 || c.is_active === true)
    .map((c: any) => ({
      id: c.id,
      uuid: c.uuid,
      name: c.name ?? '',
      action: c.action ?? c.ctaEnum?.action ?? '',
      view: (c.view ?? c.ctaEnum?.view ?? 'list') as ChapsView,
      label: c.label ?? c.ctaEnum?.label ?? c.name ?? '',
      icon: c.icon ?? c.ctaEnum?.icon ?? undefined,
      sequence: c.sequence ?? c.ctaEnum?.sequence ?? 0,
      is_active: c.is_active,
      roles: Array.isArray(c.roles) ? c.roles : [],
    }))
    .filter((c: CTA) => c.action && c.name)
}

// Dans fetchMetadata, après le parsing fields :
const allCtas: CTA[] = [
  ...parseCtas(listeView),
  ...parseCtas(detailView),
  ...parseCtas(createView),
  ...parseCtas(updateView),
]
// Tri par sequence pour ordre d'affichage stable
allCtas.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))

const parsed: ModuleMetadata = {
  // ...champs existants...
  ctas: allCtas,
  permissions: extractPermissions(data?.permissions),  // cf. F-02
  badges: Array.isArray(listeView?.badges) ? listeView.badges : [],
}
```

Ajouter en bas du composable, dans le `return` :
```ts
return {
  // ...existant...
  ctas: computed(() => metadata.value?.ctas ?? []),
  ctasFor: (scope: ChapsView) =>
    metadata.value?.ctas.filter(c => c.view === scope) ?? [],
  permissions: computed(() => metadata.value?.permissions),
  badges: computed(() => metadata.value?.badges ?? []),
  can,  // mis à jour cf. F-11
}
```

**Test** : sur un module avec CTAs configurées, `ctasFor('list')` doit renvoyer un tableau non vide.

**Dépendance** : F-04 (élargir le type CTA) doit être fait en premier.

---

## F-02 — `data.permissions` jamais extrait de la réponse CHAPS

**Sévérité** : Critical
**Fichier** : `composables/useMetadata.ts`
**Lignes** : absence

**Symptôme** : la décision "L'utilisateur peut-il créer ?" est prise par CASL frontend hardcodé au lieu de la matrice CHAPS configurée par admin.

**Cause racine** : CHAPS retourne dans `data.permissions` un objet `{ read, create, update, delete, mass_update, mass_delete, report, execute, mass_execute }` mais le frontend ne le lit pas.

**Note importante** : selon la décision D3, on **n'utilise pas** ce bloc directement pour décider de l'affichage. La présence d'une CTA correspondante dans le pipeline B est suffisante. Cependant, on extrait quand même `permissions` pour permettre la dérivation rapide et pour debug.

**Fix** :

Ajouter dans `composables/useMetadata.ts` :

```ts
import type { ChapsPermissions } from '@/types/chaps'

function extractPermissions(raw: any): ChapsPermissions {
  return {
    read: raw?.read === true,
    create: raw?.create === true,
    update: raw?.update === true,
    delete: raw?.delete === true,
    mass_update: raw?.mass_update === true,
    mass_delete: raw?.mass_delete === true,
    report: raw?.report === true,
    execute: raw?.execute === true,
    mass_execute: raw?.mass_execute === true,
  }
}
```

Et l'utiliser comme montré dans F-01.

**Test** : appeler `/v1/metadata/{module}` avec un rôle `agency` ; `metadata.value.permissions.delete` doit refléter ce qu'a configuré l'admin dans `pages/admin/ctas.vue`.

**Dépendance** : aucune.

---

## F-03 — `DetailPanel` confond `availableActions` (workflow) avec permissions CRUD

**Sévérité** : Critical
**Fichier** : `components/crud/DetailPanel.vue`
**Lignes** : 57–69, 344–372

**Symptôme** : sur un PNR `'En attente'`, les boutons Modifier/Supprimer/Dupliquer disparaissent silencieusement parce que `availableActions = ['link', 'pricing', 'confirm_pricing']` ne contient ni `'create'`, `'update'`, ni `'delete'`.
Inversement, sur un module sans aucune CTA workflow, `availableActions = []` et le code retombe sur `{ create: true, update: true, delete: true }` — ouverture totale silencieuse.

**Cause racine** : confusion entre workflow CTAs (Pipeline C) et permissions CRUD (Pipeline B).

**Code actuel** :
```ts
// Permissions from availableActions (backend) — fallback: all true (backend enforces)
const permissions = computed(() => {
  const actions = detailRecord.value?.availableActions
  if (actions?.length) {
    return {
      read: true,
      create: actions.includes('create'),
      update: actions.includes('update') || actions.includes('edit'),
      delete: actions.includes('delete'),
    }
  }
  return { read: true, create: true, update: true, delete: true }
})
```

**Fix** :

Remplacer le `permissions` actuel par :

```ts
import { mapIcon } from '@/utils/iconMap'  // cf. F-05

// CTAs CHAPS pour la vue detail (Pipeline B)
const detailCtas = computed(() => {
  if (!props.metadata?.ctas) return []
  return props.metadata.ctas.filter(c => c.view === 'detail')
})

// Workflow CTAs venant de l'instance (ESTAIR availableActions)
// Note: une fois E-01 appliqué, ces actions seront aussi des entrées ctas_enum
const workflowActions = computed<string[]>(() =>
  detailRecord.value?.availableActions ?? [],
)

// CTAs effectivement affichables sur cette ligne :
// - CTAs CRUD standard (create/update/delete) : visibles si déclarées dans CHAPS pour la vue detail
// - CTAs workflow (issue_pnr, void_ticket, ...) : visibles si déclarées ET listées par availableActions
const visibleCtas = computed(() => {
  const STANDARD = new Set(['create', 'update', 'delete', 'read', 'report'])
  return detailCtas.value.filter((cta) => {
    if (STANDARD.has(cta.action)) return true
    // CTAs workflow : doivent ÉGALEMENT figurer dans availableActions
    return workflowActions.value.includes(cta.action) || workflowActions.value.includes(cta.name)
  })
})

// Helper d'invocation unifié
function handleCta(cta: CTA) {
  switch (cta.action) {
    case 'create':
      handleDuplicate()
      break
    case 'update':
      handleEdit()
      break
    case 'delete':
      showDeleteConfirm.value = true
      break
    default:
      // CTAs métier : émettre vers la page parente, qui sait quoi faire
      emit('cta-action', { cta, record: detailData.value })
  }
}
```

Et remplacer la section ACTION BAR (lignes 343–373) par :

```vue
<div class="detail-panel__actions">
  <VBtn
    v-for="cta in visibleCtas"
    :key="cta.name"
    size="small"
    variant="tonal"
    :color="cta.action === 'delete' ? 'error' : (cta.action === 'update' ? 'primary' : undefined)"
    :prepend-icon="mapIcon(cta.icon)"
    @click="handleCta(cta)"
  >
    {{ cta.label }}
  </VBtn>
</div>
```

Ajouter aux émissions du composant :
```ts
const emit = defineEmits<{
  'push-panel': [entry: PanelStackEntry]
  'deleted': []
  'cta-action': [payload: { cta: CTA; record: Record<string, any> }]
}>()
```

**Test** :
1. Configurer dans CHAPS Admin un CTA `update_detail` pour le rôle `agent` sur le module `accounts`. Recharger ; le bouton "Modifier" doit apparaître.
2. Décocher ; le bouton doit disparaître.
3. Sur un PNR avec `status='En attente'`, après E-01 + C-05, les boutons `link`, `pricing`, `confirm_pricing` doivent apparaître AVEC le bon icon mappé.

**Dépendance** : F-01, F-02, F-04, F-05.

---

## F-04 — Type `CTA` trop rigide

**Sévérité** : High
**Fichier** : `types/chaps.ts`
**Lignes** : 73–77

**Symptôme** : impossible d'ajouter des CTAs métier (`void_ticket`, `refund_ticket`, `issue_pnr`, etc.) sans erreur TypeScript.

**Code actuel** :
```ts
export interface CTA {
  action: 'create' | 'update' | 'delete' | 'execute' | 'report' | 'refresh'
  label: string
  icon: string
}
```

**Fix** : remplacer entièrement la section CTA + permissions du fichier par :

```ts
export type ChapsView = 'list' | 'detail' | 'create' | 'update'

// String pour permettre les CTAs métier dynamiques (void_ticket, refund_ticket, issue_pnr, ...)
// Les actions canoniques (create/update/delete/execute/report/refresh) sont toujours valides.
export type ChapsAction = string

export interface CTA {
  id?: number
  uuid?: string
  name: string                  // ex: 'update_detail', 'void_ticket_detail'
  action: ChapsAction           // ex: 'update', 'void_ticket'
  view: ChapsView               // ex: 'detail'
  label: string                 // ex: 'Modifier', 'Annuler le billet'
  icon?: string                 // ex: 'icon-edit' (à mapper via mapIcon)
  sequence?: number
  is_active?: 0 | 1 | boolean
  roles?: string[]              // déjà filtré côté serveur, info indicative
}

export interface ChapsPermissions {
  read: boolean
  create: boolean
  update: boolean
  delete: boolean
  mass_update: boolean
  mass_delete: boolean
  report: boolean
  execute: boolean
  mass_execute: boolean
}
```

Et étendre `ModuleMetadata` :
```ts
export interface ModuleMetadata {
  module_name: string
  module_id: number
  listView: FieldMetadata[]
  createView: FieldMetadata[]
  updateView: FieldMetadata[]
  createViewBlocs?: DetailBloc[]
  updateViewBlocs?: DetailBloc[]
  detailView: DetailViewFields
  ctas: CTA[]                     // NOUVEAU
  permissions: ChapsPermissions   // NOUVEAU
  badges?: any[]                  // NOUVEAU
}
```

**Test** : `npm run build` passe sans erreur TS.

**Dépendance** : aucune.

---

## F-05 — Pas de mapping icônes CHAPS (`icon-*`) → frontend (`tabler-*`)

**Sévérité** : High
**Fichier** : nouveau — `utils/iconMap.ts`

**Symptôme** : si on rend dynamiquement les icônes des CTAs CHAPS, Vuetify ne les trouve pas (Iconify ne connaît que les sets `tabler:`, `mdi:`, `fa:` ; `icon:` n'existe pas).

**Cause racine** : la base CHAPS stocke des préfixes `icon-edit`, `icon-delete`, `icon-eye`, etc. (legacy). Le frontend Vuexy utilise `tabler-*`.

**Fix** : créer le fichier `utils/iconMap.ts` :

```ts
/**
 * Mapping des icônes CHAPS legacy (icon-*) vers Tabler Icons utilisées par Vuexy.
 *
 * Source : ctas_enum.icon en base CHAPS, valeurs vues : icon-user-plus, icon-edit,
 * icon-delete, icon-download, icon-play, icon-eye.
 *
 * Stratégie : mapping explicite pour les valeurs connues, fallback sur substitution
 * 'icon-' → 'tabler-' pour les inconnues. Si aucune icône n'est fournie, retourne
 * une icône par défaut neutre.
 */
const ICON_MAP: Record<string, string> = {
  // Icônes CRUD canoniques (ctas_enum)
  'icon-user-plus': 'tabler-plus',
  'icon-edit': 'tabler-edit',
  'icon-delete': 'tabler-trash',
  'icon-eye': 'tabler-eye',
  'icon-download': 'tabler-download',
  'icon-play': 'tabler-player-play',
  'icon-refresh': 'tabler-refresh',
  // Icônes par défaut éventuelles
  'icon-default': 'tabler-circle',
  // Workflow / métier (à compléter au fur et à mesure de l'extension de ctas_enum)
  'icon-link': 'tabler-link',
  'icon-calculator': 'tabler-calculator',
  'icon-check': 'tabler-check',
  'icon-x': 'tabler-x',
  'icon-arrow-back': 'tabler-arrow-back-up',
  'icon-send': 'tabler-send',
  'icon-shield-check': 'tabler-shield-check',
}

const DEFAULT_ICON = 'tabler-circle'

export function mapIcon(chapsIcon?: string | null): string {
  if (!chapsIcon) return DEFAULT_ICON
  if (chapsIcon in ICON_MAP) return ICON_MAP[chapsIcon]
  // Fallback heuristique : essai 'icon-foo' → 'tabler-foo'
  if (chapsIcon.startsWith('icon-')) {
    return 'tabler-' + chapsIcon.slice('icon-'.length)
  }
  // Préfixe inconnu — laisser tel quel (peut être déjà tabler-)
  return chapsIcon
}
```

**Test** :
- `mapIcon('icon-edit')` → `'tabler-edit'`
- `mapIcon('icon-shield-check')` → `'tabler-shield-check'`
- `mapIcon('icon-foo-bar')` → `'tabler-foo-bar'` (fallback)
- `mapIcon(undefined)` → `'tabler-circle'`
- `mapIcon('tabler-existing')` → `'tabler-existing'` (passthrough)

**Dépendance** : aucune.

---

## F-06 — Commentaire trompeur sur la convention picklist

**Sévérité** : Low
**Fichier** : `composables/useMetadata.ts`
**Lignes** : 99–101

**Symptôme** : code mortel pour mainteneur. Le commentaire affirme que ESTAIR stocke `name` (machine ID) alors qu'il stocke `value` (literal enum).

**Code actuel** :
```ts
// CHAPS picklists: { name: "module_field_val" (machine ID), value: "val" (display), color_front }
// Frontend expects: { label (display), value (match key), color_font }
// ESTAIR data stores the picklist `name` as field value, so we keep `name` for matching
```

**Fix** : remplacer par :
```ts
// CHAPS picklists: { name: "{module}_{field}_{value}" (machine ID composite),
//                    field: "<column>", value: "<literal stored value>",
//                    color_back: "#hex", color_front: "#hex" }
// ESTAIR stocke `value` dans la colonne (issu de MySQL enum('a','b',...)).
// Le matching côté frontend se fait DONC sur `option.value === record.field`.
// Le `name` n'est qu'un identifiant administratif CHAPS, jamais affiché.
```

**Test** : grep du code après application : aucune occurrence du commentaire erroné.

**Dépendance** : aucune.

---

## F-07 — Branche `picklistColor` Vuetify named = code mort

**Sévérité** : Low
**Fichier** : `components/app/FieldRenderer.vue`
**Lignes** : 73–75, 164–172

**Symptôme** : aucun, mais le code suggère qu'une autre source que `colors.color_back/front` peut alimenter les chips. Faux — CHAPS ne retourne que des hex.

**Code actuel** :
```ts
const picklistColor = computed(() => {
  return picklistOption.value?.color ?? undefined
})
```
Et plus bas :
```vue
<!-- Select / Picklist with Vuetify named color -->
<VChip
  v-else-if="['select', 'picklist'].includes(type) && picklistColor"
  :color="picklistColor"
  size="small"
  label
>
  {{ picklistLabel }}
</VChip>
```

**Fix** : supprimer le `computed picklistColor` (lignes 73–75) et la branche template (lignes 164–172). La branche `color_back` (lignes 152–162) couvre tous les cas.

**Test** : sur un module avec picklists colorées (ex. `accounts.status`), les chips s'affichent identiquement avant/après suppression.

**Dépendance** : aucune.

---

## F-08 — `slice(0, 10)` arbitraire sur les colonnes

**Sévérité** : Medium
**Fichier** : `composables/useDynamicColumns.ts`
**Lignes** : 16–37

**Symptôme** : modules avec plus de 10 fields `list=1` perdent silencieusement les colonnes suivantes (ex. `etatsdeventes` qui a typiquement 15+ champs visibles).

**Cause racine** : limite hardcodée pour la lisibilité — mais ne respecte pas la décision admin CHAPS.

**Code actuel** :
```ts
const EXCLUDED_FIELDS = new Set([
  'airtable_record_id', 'airtable_id',
  'office_id_verified', 'office_id_verified_at',
  /* ... liste hardcodée ... */
])
const MAX_LIST_COLUMNS = 10

const filteredFields = computed(() => {
  const fieldList = toValue(fields)
  if (!fieldList?.length) return []
  return fieldList
    .filter(f => !EXCLUDED_FIELDS.has(f.fieldname))
    .slice(0, MAX_LIST_COLUMNS)
})
```

**Fix** :

```ts
// Champs internes / techniques jamais affichés en liste, indépendamment de list=1
// Note : ils peuvent rester visibles en detail (header) si CHAPS les marque header=1
const EXCLUDED_FIELDS = new Set([
  'uuid', 'account_id',
  'airtable_record_id', 'airtable_id',
  'password', 'remember_token', 'api_token',
  'is_deleted', 'is_active',
  'created_by', 'updated_by', 'cancelled_by', 'modified_by',
  'verification_token', 'verification_expires_at',
  'air_raw_content', 'air_json', 'estair_json',
])

export function useDynamicColumns(fields: MaybeRefOrGetter<FieldMetadata[]>) {
  // Ne pas tronquer : respecter la liste CHAPS. L'admin contrôle list=0/1 par champ.
  // L'utilisateur peut masquer/réordonner via l'UI (column visibility, planifié P3).
  const filteredFields = computed(() => {
    const fieldList = toValue(fields)
    if (!fieldList?.length) return []
    return fieldList
      .filter(f => !EXCLUDED_FIELDS.has(f.fieldname))
      .filter(f => f.is_active !== 0 && f.is_deleted !== 1)
      .sort((a, b) => (a.sequencelist ?? 0) - (b.sequencelist ?? 0))
  })
  // ... reste inchangé ...
}
```

**Test** : sur `etatsdeventes`, le tableau doit afficher tous les champs marqués `list=1` dans CHAPS metadata, sans troncature à 10.

**Dépendance** : aucune.

---

## F-09 — `slice(0, 4)` arbitraire sur les header chips du DetailPanel

**Sévérité** : Medium
**Fichier** : `components/crud/DetailPanel.vue`
**Lignes** : 137–144

**Symptôme** : si CHAPS définit 6 fields `header=1`, seuls 4 chips apparaissent dans le panneau detail. Pas de message d'erreur.

**Code actuel** :
```ts
const HEADER_EXCLUDED = new Set(['name', 'uuid', 'id', 'account_id'])

const headerFields = computed(() =>
  props.metadata?.detailView?.headerFields
    ?.filter(hf => !HEADER_EXCLUDED.has(hf.fieldname))
    ?.slice(0, 4) ?? [],
)
```

**Fix** :
```ts
// Exclusions techniques (les autres viennent de la flag header=1 en CHAPS)
const HEADER_EXCLUDED = new Set(['uuid', 'account_id', 'is_deleted', 'is_active'])

const headerFields = computed(() => {
  const fields = props.metadata?.detailView?.headerFields ?? []
  return fields
    .filter(hf => !HEADER_EXCLUDED.has(hf.fieldname))
    .filter(hf => hf.is_active !== 0)
    .sort((a, b) => (a.sequencedetail ?? 0) - (b.sequencedetail ?? 0))
})
```

**Test** : configurer 5 champs `header=1` dans CHAPS Admin pour un module ; vérifier qu'ils apparaissent tous (avec scroll horizontal si débordement).

**Dépendance** : C-04 (tri serveur) recommandé en complément.

---

## F-10 — `&& item[col.key]` masque `0` et `false` pour picklists/booleans

**Sévérité** : Low
**Fichier** : `components/app/EstairInlineTable.vue`
**Lignes** : 117

**Symptôme** : si une picklist a une option avec `value = 0` (rare mais possible), le chip ne s'affiche pas.

**Code actuel** :
```vue
<VChip
  v-if="['select', 'picklist'].includes(col.fieldType) && item[col.key]"
  ...
>
```

**Fix** :
```vue
<VChip
  v-if="['select', 'picklist'].includes(col.fieldType) && item[col.key] != null && item[col.key] !== ''"
  ...
>
```
Et idem ligne 130 pour le placeholder :
```vue
<span
  v-else-if="['select', 'picklist'].includes(col.fieldType)"
  class="text-disabled"
>—</span>
```

**Test** : créer un picklist avec `value = '0'` ou `value = false` ; le chip doit s'afficher.

**Dépendance** : aucune.

---

## F-11 — Page module utilise `usePermissions` (CASL frontend) au lieu de `can()` CHAPS

**Sévérité** : High
**Fichier** : `pages/app/[domaine]/[module]/index.vue`
**Lignes** : 21–27, 81–86, 88–90

**Symptôme** : les boutons de la toolbar (Nouveau, SPC, Filtres, Export) se basent sur la matrice CASL hardcodée, pas sur les CTAs CHAPS.

**Cause racine** : double source de vérité ; `perms.value.create || can('create')` retombe toujours sur `perms` puisque `can()` retournait toujours faux (cf. F-01).

**Code actuel** :
```ts
const { metadata, listViewFields, loading: metaLoading, can } = useMetadata(moduleName)
const { items, total, indicators, loading: dataLoading, pagination, fetchAll, create, update, remove } = useEstair(moduleName)
const { headers, headersWithActions } = useDynamicColumns(listViewFields)
// ...
const { getModulePermissions } = usePermissions()
// ...
const perms = computed(() => getModulePermissions(moduleName.value))
const hasCreate = computed(() => perms.value.create || can('create'))
const hasUpdate = computed(() => perms.value.update || can('update'))
const hasDelete = computed(() => perms.value.delete || can('delete'))
const hasCrud = computed(() => hasUpdate.value || hasDelete.value)

// Module-specific features
const hasSpc = computed(() => ['etatsdeventes', 'pnr'].includes(moduleName.value))
const hasExport = computed(() => !['comptesbancaires', 'soldes', 'soldesbmp', 'users'].includes(moduleName.value))
```

**Fix** :

```ts
import { mapIcon } from '@/utils/iconMap'

const {
  metadata, listViewFields, loading: metaLoading,
  ctasFor, can,
} = useMetadata(moduleName)
const {
  items, total, indicators, loading: dataLoading,
  pagination, fetchAll, create, update, remove,
} = useEstair(moduleName)
const { headers, headersWithActions } = useDynamicColumns(listViewFields)

// CHAPS = source unique de vérité (pas de fallback CASL)
const listCtas = computed(() => ctasFor('list'))

// Helpers dérivés du Pipeline B uniquement
const hasCreate = computed(() => can('create', 'list'))
const hasUpdate = computed(() => can('update', 'detail'))
const hasDelete = computed(() => can('delete', 'detail'))
const hasMassUpdate = computed(() => can('update', 'list'))
const hasMassDelete = computed(() => can('delete', 'list'))
const hasReport = computed(() => can('report', 'list'))
const hasExecute = computed(() => can('execute', 'list'))
const hasCrud = computed(() => hasUpdate.value || hasDelete.value)

// CTAs métier custom (tout ce qui n'est pas CRUD canonique)
const STANDARD = new Set(['create', 'update', 'delete', 'read', 'report', 'execute', 'refresh'])
const customListCtas = computed(() =>
  listCtas.value.filter(c => !STANDARD.has(c.action)),
)
```

Mettre à jour le template pour ajouter les CTAs custom :
```vue
<!-- LEFT: Show N + CTAs CHAPS -->
<div class="d-flex gap-4 align-center flex-wrap">
  <div class="d-flex align-center gap-x-2">
    <span class="text-body-1">Afficher</span>
    <AppSelect
      :model-value="itemsPerPage"
      :items="pageSizeOptions"
      style="inline-size: 5.5rem;"
      @update:model-value="onPageSizeChange"
    />
  </div>

  <VBtn
    v-if="hasCreate"
    color="primary"
    prepend-icon="tabler-plus"
    @click="openCreate"
  >
    Nouveau
  </VBtn>

  <!-- CTAs métier custom (issue_pnr_list, mass_void, ...) -->
  <VBtn
    v-for="cta in customListCtas"
    :key="cta.name"
    variant="tonal"
    :prepend-icon="mapIcon(cta.icon)"
    @click="handleCustomCta(cta)"
  >
    {{ cta.label }}
    <VBadge v-if="selectedIds.length" :content="selectedIds.length" inline class="ms-1" />
  </VBtn>
</div>
```

Et la fonction `handleCustomCta` :
```ts
function handleCustomCta(cta: CTA) {
  // Pour PNR : load_pnr garde le comportement actuel
  if (cta.action === 'load_pnr' || cta.name === 'load_pnr_list') {
    spcLoadPnrMode.value = true
    spcDialogOpen.value = true
    return
  }
  // Autres CTAs : émission générique vers SPC dialog avec l'opération
  if (selectedIds.value.length === 0) {
    // Avertir : il faut sélectionner au moins une ligne pour les actions de masse
    return
  }
  spcLoadPnrMode.value = false
  // Note : SpcOperationDialog devra accepter cta.action en initialOperation
  spcDialogOpen.value = true
}
```

Et **supprimer** :
```ts
// SUPPRIMER : import et instanciation de usePermissions
// SUPPRIMER : ligne `const { getModulePermissions } = usePermissions()`
// SUPPRIMER : ligne `const perms = computed(...)`
```

**Test** : vérifier que désactiver un CTA dans CHAPS Admin masque immédiatement le bouton correspondant après refresh.

**Dépendance** : F-01, F-02, F-04, F-05, F-13.

---

## F-12 — Mapping rétrocompatibilité des rôles à supprimer

**Sévérité** : High
**Fichier** : `stores/auth.ts`
**Lignes** : 60–77

**Symptôme** : couche de mapping qui n'a plus lieu d'être après migration Keycloak.

**Code actuel** :
```ts
function mapKeycloakRole(roles: string[]): CeterisRole {
  // Nouveaux noms canoniques
  if (roles.includes('admin')) return 'admin'
  if (roles.includes('support')) return 'support'
  if (roles.includes('sale')) return 'sale'
  if (roles.includes('finance')) return 'finance'
  if (roles.includes('agency')) return 'agency'
  if (roles.includes('agent')) return 'agent'
  if (roles.includes('viewer')) return 'viewer'

  // Rétrocompatibilité avec les anciens noms de rôles Keycloak
  if (roles.includes('super_admin')) return 'admin'
  if (roles.includes('admin_agence')) return 'agency'
  if (roles.includes('manager')) return 'support'
  if (roles.includes('comptable')) return 'finance'

  return 'viewer'
}
```

**Fix** :
```ts
const CANONICAL_ROLES: CeterisRole[] = [
  'admin', 'support', 'sale', 'finance', 'agency', 'agent', 'viewer',
]

function mapKeycloakRole(roles: string[]): CeterisRole {
  const found = CANONICAL_ROLES.find(r => roles.includes(r))
  return found ?? 'viewer'
}
```

**Préreq côté Keycloak** : avant d'appliquer ce patch, s'assurer que tous les utilisateurs en prod ont leur rôle canonique côté Keycloak. Sinon ils tomberont en `viewer` par défaut.

**Test** : connexion avec un utilisateur dont le token contient `roles: ['agency']` → `userData.role === 'agency'`.

**Dépendance** : D1 (Keycloak migré).

---

## F-13 — Composable `usePermissions` à retirer (matrice CASL hardcodée)

**Sévérité** : Medium
**Fichier** : `composables/usePermissions.ts`
**Lignes** : tout le fichier

**Symptôme** : seconde source de vérité concurrente à CHAPS. Drift garanti à terme.

**Cause racine** : matrice statique `PERMISSION_MATRIX` qui réplique la décision CHAPS, et nécessite une release frontend pour chaque évolution.

**Fix** : supprimer le fichier entièrement. Auditer tous les imports et les remplacer par les helpers CHAPS :

```bash
# Avant suppression, lister les usages
grep -rn "usePermissions\|getModulePermissions\|PERMISSION_MATRIX" \
  --include="*.ts" --include="*.vue" \
  | grep -v node_modules
```

Pour chaque usage trouvé, remplacer :
- `usePermissions().getModulePermissions(m)` → utiliser `can()` du `useMetadata(m)`
- `usePermissions().canAccessModule(m)` → vérifier la présence dans les menus retournés par CHAPS (cf. `useChapsAdmin.getMenus()`)
- `usePermissions().hasPermission(m, action)` → `can(action)` du `useMetadata(m)` correspondant

**Note pour le navigationGuard** : le middleware `auth.global.ts` peut continuer à utiliser une vérification simplifiée (rôle dans la liste canonique) ; les vérifications fines viennent de CHAPS au moment du rendu.

**Test** : `npm run build` passe sans erreurs après suppression du composable.

**Dépendance** : F-11 appliqué d'abord (consommateur principal).

---

## F-14 — `FieldEditor` mappe `value: o.value` — convention OK mais à documenter

**Sévérité** : Low
**Fichier** : `components/app/FieldEditor.vue`
**Lignes** : 30–35

**Symptôme** : aucun, le code est correct.

**Cause racine** : passage en revue suite à confusion historique sur la convention picklist (cf. F-06). Mérite un commentaire explicite.

**Code actuel** :
```ts
const picklistItems = computed(() => {
  if (!props.field.options?.length) return []
  return props.field.options
    .filter(o => o.is_active !== false && o.is_active !== 0)
    .map(o => ({ title: o.label || o.name || String(o.value), value: o.value }))
})
```

**Fix** : ajouter un commentaire et corriger l'ordre du fallback `title` (selon convention D5, `value` est aussi le label par défaut, `name` ne doit JAMAIS être affiché) :
```ts
// Picklist items pour VSelect
// Convention CHAPS : option.value = la valeur littérale stockée par ESTAIR
//                    et également le label par défaut (sauf option.label override)
//                    option.name = ID machine, NE JAMAIS L'AFFICHER
const picklistItems = computed(() => {
  if (!props.field.options?.length) return []
  return props.field.options
    .filter(o => o.is_active !== false && o.is_active !== 0)
    .map(o => ({
      title: o.label || String(o.value ?? ''),
      value: o.value,
    }))
})
```

**Test** : l'édition d'un record avec picklist est inchangée.

**Dépendance** : aucune.

---

## F-15 — `FieldRenderer` : branches mutuellement exclusives sur color_back

**Sévérité** : Medium
**Fichier** : `components/app/FieldRenderer.vue`
**Lignes** : 150–177

**Symptôme** : si une option a `color_back` mais pas `color_font`, le texte hérite de la couleur Vuetify auto-contrastée, qui peut clasher avec le design CHAPS. Inversement si seul `color_font` sans `color_back`, pas de chip.

**Cause racine** : conditions `v-else-if` trop strictes.

**Code actuel** :
```vue
<!-- Select / Picklist with custom background/font colors -->
<VChip
  v-else-if="['select', 'picklist'].includes(type) && picklistOption?.color_back"
  :style="{
    backgroundColor: picklistOption.color_back,
    color: picklistOption.color_font || '#374151',
    borderColor: 'transparent',
  }"
  size="small"
  label
>
  {{ picklistLabel }}
</VChip>

<!-- Select / Picklist with Vuetify named color -->
<VChip
  v-else-if="['select', 'picklist'].includes(type) && picklistColor"
  :color="picklistColor"
  size="small"
  label
>
  {{ picklistLabel }}
</VChip>

<!-- Select / Picklist without color -->
<span v-else-if="['select', 'picklist'].includes(type)">
  {{ picklistLabel }}
</span>
```

**Fix** : factoriser en une seule branche qui compose dynamiquement les styles :

```vue
<!-- Select / Picklist : un seul branchement, styles composés -->
<template v-else-if="['select', 'picklist'].includes(type)">
  <VChip
    v-if="picklistOption?.color_back || picklistOption?.color_font"
    :style="{
      backgroundColor: picklistOption.color_back || undefined,
      color: picklistOption.color_font || (picklistOption.color_back ? '#fff' : undefined),
      borderColor: 'transparent',
    }"
    size="small"
    label
  >
    {{ picklistLabel }}
  </VChip>
  <span v-else>
    {{ picklistLabel }}
  </span>
</template>
```

Et en parallèle, **supprimer** `picklistColor` (cf. F-07).

**Test** :
- Option avec uniquement `color_back: '#ffe8d0'` → chip avec fond, texte blanc auto.
- Option avec `color_back + color_front` → chip avec les 2 couleurs CHAPS.
- Option sans couleur → texte simple.

**Dépendance** : F-07 (suppression code mort).

---

# 2. ESTAIR Backend (Laravel)

## E-01 — `getAvailableActions` retourne des clés non alignées sur `ctas_enum`

**Sévérité** : Critical
**Fichier** : `app/Http/Controllers/api/v1/CrudController.php`
**Lignes** : 288–323

**Symptôme** : les CTAs métier retournées (`link`, `pricing`, `void_ticket`, etc.) ne peuvent pas être croisées avec les CTAs déclarées dans CHAPS (qui n'en contient aucune actuellement).

**Cause racine** : ces actions sont définies en dur dans le code PHP, hors du référentiel CHAPS. Solution : étendre `ctas_enum` (cf. C-05) et faire retourner par `getAvailableActions()` les **noms de CTAs** (= `ctas_enum.name`), puis le frontend croise avec les CTAs déclarées.

**Code actuel** :
```php
private function getAvailableActions($objectName, $element)
{
    $actions = [];
    switch(strtolower($objectName)) {
        case 'pnr':
            if (isset($element->status)) {
                if ($element->status === 'En attente') {
                    $actions = ['link', 'pricing', 'confirm_pricing'];
                } elseif ($element->status === 'A émettre') {
                    $actions = ['issue_pnr'];
                }
            }
            break;
        case 'etatsdeventes':
            if (isset($element->status) && in_array($element->status, ['issue', 'reissue'])) {
                $isToday = false;
                if (isset($element->date)) {
                    $elementDate = date('Y-m-d', strtotime($element->date));
                    $today = date('Y-m-d');
                    $isToday = ($elementDate === $today);
                }
                $actions = $isToday ? ['void_ticket'] : ['refund_ticket'];
            }
            break;
        case 'accounts':
            if (isset($element->status) && $element->status === 'En attente') {
                $actions = ['office_id'];
            }
            break;
    }
    return $actions;
}
```

**Fix** :

```php
/**
 * Retourne les noms de CTAs (ctas_enum.name) éligibles pour cet élément
 * en fonction de son état métier. La déclaration des CTAs (label, icon,
 * roles) reste centralisée dans CHAPS via ctas_enum.
 *
 * Convention de nommage : {action}_{view}, e.g. 'void_ticket_detail'.
 *
 * @return string[] Noms de CTAs (correspondants à ctas_enum.name)
 */
private function getAvailableActions($objectName, $element)
{
    $actions = [];

    switch (strtolower($objectName)) {
        case 'pnr':
        case 'pnrs':
            if (!isset($element->status)) break;
            if ($element->status === 'En attente') {
                $actions = ['link_detail', 'pricing_detail', 'confirm_pricing_detail'];
            } elseif ($element->status === 'A émettre') {
                $actions = ['issue_pnr_detail'];
            }
            break;

        case 'etatsdeventes':
            if (!isset($element->status)) break;
            if (!in_array($element->status, ['issue', 'reissue'], true)) break;

            $isToday = false;
            if (!empty($element->date)) {
                $elementDate = date('Y-m-d', strtotime((string) $element->date));
                $isToday = ($elementDate === date('Y-m-d'));
            }
            $actions = $isToday ? ['void_ticket_detail'] : ['refund_ticket_detail'];
            break;

        case 'accounts':
            if (isset($element->status) && $element->status === 'En attente') {
                $actions = ['office_id_detail'];
            }
            break;
    }

    return $actions;
}
```

**Notes** :
- Le frontend (cf. F-03) compare maintenant `availableActions` avec `cta.name` et `cta.action` : les deux fonctionnent.
- L'ajout de nouvelles CTAs métier (NDC : `issue_ndc_detail`, `void_ndc_detail`, `reissue_ndc_detail`) se fait dans `ctas_enum` SQL (cf. D-02) PUIS dans cette méthode pour la logique d'éligibilité par état.

**Test** :
1. Créer un PNR avec status `'En attente'`
2. `GET /v1/admin/pnrs/{uuid}` → `record.availableActions` = `['link_detail', 'pricing_detail', 'confirm_pricing_detail']`
3. Côté frontend, après C-05, les 3 boutons apparaissent dans le DetailPanel avec icônes mappées.

**Dépendance** : C-05, D-02 (les `ctas_enum` correspondantes doivent exister en CHAPS).

---

## E-02 — `addEnumLabels` retiré, sans utilité

**Sévérité** : Medium
**Fichier** : `app/Http/Controllers/api/v1/CrudController.php`
**Lignes** : 251–252, 1540–1589

**Symptôme** : aucun à court terme (la config ne couvre que `financial_requests`), mais charge inutile à la maintenance et duplication potentielle avec les picklists CHAPS.

**Cause racine** : la liste des picklists est fournie par CHAPS dans la metadata (avec `value` = label par défaut). Pas besoin d'ajouter des `*_label` côté backend.

**Code actuel — appel ligne 251–252** :
```php
// Enrichir avec les labels traduits pour les champs enum (category_label, status_label, etc.)
$data = $this->addEnumLabels($data, $modulename);
```

**Code actuel — fonctions 1540–1589** :
```php
private function addEnumLabels($data, $modulename) { /* ... */ }
private function getEnumFieldsConfig($modulename) { /* ... */ }
```

**Fix** :

1. Supprimer l'appel ligne 251–252 :
```php
// SUPPRIMÉ :
// $data = $this->addEnumLabels($data, $modulename);
```

2. Supprimer les deux méthodes privées `addEnumLabels` et `getEnumFieldsConfig` (lignes 1540–1589).

3. Si certains modules avaient des libellés i18n configurés, les migrer dans CHAPS en passant par le mécanisme picklist (champ `label` à ajouter via D-02 si besoin).

**Note** : si financial_requests dépend réellement de cette i18n, vérifier d'abord que les picklists CHAPS pour ce module ont des `value` cohérents. Si oui, retirer sans risque.

**Test** :
1. `GET /v1/admin/financial_requests` ne contient plus de `*_label` dans les records
2. Frontend : les chips picklist s'affichent toujours correctement (vu que le matching se fait sur `option.value`)

**Dépendance** : aucune.

---

## E-03 — Casse incohérente : `strtolower($modulename)` vs préservation

**Sévérité** : Medium
**Fichier** : `app/Http/Controllers/api/v1/backoffice/MetadataController.php`
**Lignes** : 22

**Symptôme** : si une table MySQL est créée avec une majuscule (cas Windows en dev local), divergence entre noms dispatchés (lowercase) et noms réels en DB.

**Code actuel** :
```php
$columns = \DB::select('SHOW FULL COLUMNS FROM ' . strtolower($modulename));
```

**Fix** : aligner sur le nom tel que retourné par `SHOW TABLES`, sans transformation :

```php
public function dispatchMetadata(Request $request)
{
    $modulesResponse = $this->getModules();
    $modulesData = json_decode($modulesResponse->getContent(), true);
    $allowedModules = array_column($modulesData['modules'], 'name');

    $metadatas = [];
    foreach ($allowedModules as $modulename) {
        // Pas de strtolower : le nom vient de SHOW TABLES
        $columns = \DB::select(
            'SHOW FULL COLUMNS FROM ' . $this->quoteIdentifier($modulename)
        );
        // ...
    }
    // ...
}

/**
 * Échappe un identifiant SQL (nom de table/colonne) pour éviter injection.
 */
private function quoteIdentifier(string $name): string
{
    if (preg_match('/^[a-zA-Z0-9_]+$/', $name) !== 1) {
        throw new \InvalidArgumentException("Nom invalide : $name");
    }
    return "`$name`";
}
```

Idem dans `getEnumValues`, `getReference` et tout endroit qui interpole le nom de module en SQL.

**Test** :
1. `dispatchMetadata` sur une DB avec table `MyTable` (majuscule) ne plante pas
2. Pas de SQL injection même avec un nom volontairement malicieux passé en metadata

**Dépendance** : aucune.

---

## E-04 — Dispatch ESTAIR → CHAPS : doit être idempotent et delta

**Sévérité** : High
**Fichier** : `app/Http/Controllers/api/v1/backoffice/MetadataController.php`
+ côté CHAPS : `app/Http/Controllers/MetadataController.php` (réception)

**Symptôme** : actuellement, à chaque dispatch, les modifications faites par l'admin dans CHAPS (libellés, blocs, séquences, list/create/update/detail/header flags) sont écrasées.

**Cause racine** : le dispatch fait du upsert brut sans préserver les colonnes "config admin".

**Fix — côté ESTAIR** :

Le dispatch doit envoyer les **schemas observés**, pas la config UI. C'est-à-dire, dans le payload envoyé à CHAPS, ne fournir que :
- `name`, `fieldname`, `displayname` (auto-généré depuis le nom de colonne)
- `type` (déduit)
- `mandatory` (depuis `IS NULLABLE`)
- `reference` (clés étrangères)
- `default`

**Ne pas envoyer** :
- `list`, `create`, `update`, `detail`, `context`, `report`, `target`, `header`
- `sequencelist`, `sequencecreate`, `sequenceupdate`, `sequencedetail`
- `blocs_id`

```php
public function dispatchMetadata(Request $request)
{
    $modulesData = json_decode($this->getModules()->getContent(), true);
    $allowedModules = array_column($modulesData['modules'], 'name');

    $metadatas = [];
    foreach ($allowedModules as $modulename) {
        $columns = \DB::select('SHOW FULL COLUMNS FROM ' . $this->quoteIdentifier($modulename));

        foreach ($columns as $key => $column) {
            $reference = $this->getReference($column->Field, $column->Key, $modulename);

            // Schema-only payload : pas de config UI
            $data = [
                "object" => $modulename,
                "type" => $this->getType($column->Type, $column->Key, $reference),
                "fieldname" => $column->Field,
                "displayname" => ucfirst(str_replace("_", " ", $column->Field)),
                "reference" => $reference,
                "name" => $modulename . '_' . $column->Field,
                "mandatory" => $column->Null == 'NO' ? 1 : 0,
                "default" => $column->Default,
                // PAS DE list, create, update, detail, sequence*, blocs_id
            ];

            if ($data["type"] == "picklist") {
                $data["enum"] = 1;
                $data["picklists"] = $this->getEnumValues($modulename, $column);
            }

            $metadatas[] = $data;
        }
    }

    return $this->ReturnResponse(true, "Metadatas chargés avec succès", $metadatas, 200);
}
```

**Fix — côté CHAPS** :

Le récepteur doit faire un **delta merge** :

```php
// CHAPS/app/Services/MetadataDispatchService.php (à créer ou adapter)

/**
 * Réception du dispatch ESTAIR : merge delta avec préservation de la config admin.
 *
 * @param array $incoming  Liste des metadatas observées dans la DB ESTAIR
 * @param string $module   Nom du module concerné
 */
public function applyDispatch(array $incoming, string $module): array
{
    $stats = ['added' => 0, 'updated' => 0, 'archived' => 0, 'preserved' => 0];

    // 1. Index des metadatas actuelles (par fieldname)
    $existing = Metadatas::where('object', $module)
        ->where('is_deleted', 0)
        ->get()
        ->keyBy('fieldname');

    $incomingFieldnames = collect($incoming)->pluck('fieldname')->all();

    foreach ($incoming as $field) {
        $current = $existing->get($field['fieldname']);

        if ($current) {
            // UPDATE : ne touche QUE les colonnes "schema-driven"
            $current->fill([
                'type' => $field['type'],
                'mandatory' => $field['mandatory'],
                'reference' => $field['reference'],
                'default' => $field['default'],
                // PAS : displayname, list, create, update, detail, sequence*, blocs_id
                // (préservés tels quels)
            ]);
            if ($current->isDirty()) {
                $current->save();
                $stats['updated']++;
            } else {
                $stats['preserved']++;
            }

            // Sync picklists du field si c'est un enum
            if (!empty($field['enum']) && !empty($field['picklists'])) {
                $this->syncPicklists($current, $field['picklists']);
            }
        } else {
            // INSERT : nouveau champ avec defaults raisonnables
            Metadatas::create(array_merge($field, [
                'object' => $module,
                'displayname' => $field['displayname'],
                'list' => 1,            // visible par défaut, admin peut désactiver
                'create' => 1,
                'update' => 1,
                'detail' => 1,
                'context' => 0,
                'report' => 0,
                'target' => 0,
                'header' => 0,
                'sequencelist' => 999,  // en bout de liste, admin réordonne
                'sequencecreate' => 999,
                'sequenceupdate' => 999,
                'sequencedetail' => 999,
                'blocs_id' => 1,        // bloc Général par défaut
                'archived' => 0,
                'is_active' => 1,
                'is_deleted' => 0,
            ]));
            $stats['added']++;
        }
    }

    // 2. ARCHIVE : champs présents en CHAPS mais plus en DB ESTAIR
    foreach ($existing as $fieldname => $current) {
        if (!in_array($fieldname, $incomingFieldnames, true)) {
            $current->update(['archived' => 1, 'is_active' => 0]);
            $stats['archived']++;
        }
    }

    return $stats;
}

/**
 * Sync picklists d'un metadata : ajoute les nouvelles, archive les disparues,
 * préserve la config admin (colors_id, sequence) sur les existantes.
 */
private function syncPicklists(Metadatas $field, array $incoming): void
{
    $module = Modules::where('name', $field->object)->first();
    if (!$module) return;

    $existing = Picklists::where('field', $field->fieldname)
        ->where('modules_id', $module->id)
        ->where('is_deleted', 0)
        ->get()
        ->keyBy('value');  // matching par valeur littérale (D5)

    $incomingValues = collect($incoming)->pluck('value')->all();

    foreach ($incoming as $pl) {
        if (!$existing->has($pl['value'])) {
            // Nouvelle picklist : créer avec defaults
            Picklists::create([
                'uuid' => $pl['uuid'] ?? \Str::uuid()->toString(),
                'name' => $pl['name'],
                'field' => $pl['field'],
                'value' => $pl['value'],
                'sequence' => count($existing) + 1,  // bout de liste
                'colors_id' => null,                  // admin assignera couleur
                'metadatas_id' => $field->id,
                'modules_id' => $module->id,
                'is_active' => 1,
                'is_deleted' => 0,
            ]);
        }
        // Picklist existante : on ne touche pas (préservation colors_id, sequence)
    }

    // Archive les picklists disparues
    foreach ($existing as $value => $current) {
        if (!in_array($value, $incomingValues, true)) {
            $current->update(['is_active' => 0]);
        }
    }
}
```

**Tâche planifiée** : ajouter dans `app/Console/Kernel.php` côté ESTAIR :

```php
protected function schedule(Schedule $schedule): void
{
    // Dispatch quotidien des metadatas vers CHAPS (delta merge)
    $schedule->call(function () {
        $controller = app(\App\Http\Controllers\api\v1\backoffice\MetadataController::class);
        $controller->dispatchMetadata(request());
    })->daily()->name('chaps-metadata-dispatch')->withoutOverlapping();
}
```

**Test** :
1. Modifier `displayname` d'un metadata dans CHAPS Admin (ex. `etatsdeventes.status` → "Statut du billet")
2. Lancer le dispatch
3. Vérifier que `displayname` est resté "Statut du billet" en CHAPS DB
4. Ajouter une colonne dans la table ESTAIR ; relancer dispatch ; vérifier que la colonne apparaît avec `list=1, sequencelist=999`
5. Supprimer une colonne en DB ESTAIR ; relancer dispatch ; vérifier que la metadata correspondante a `archived=1`

**Dépendance** : aucune (mais doit être testé sur env de staging avant prod).

---

## E-05 — `create` / `updateOne` exigent un wrapper `element` que le frontend n'envoie pas

**Sévérité** : **Critical** (P0 ABSOLUE — bloque la création et la modification dans tout le système)
**Fichier** : `app/Http/Controllers/api/v1/CrudController.php`
**Lignes** : 96–104 (create), 590–600 (updateOne)

**Symptôme** : toutes les créations et modifications depuis le frontend renvoient HTTP 400 « The element field is required ou modulename manquant ». L'utilisateur clique sur Enregistrer dans n'importe quel formulaire → erreur silencieuse, le panel reste ouvert, aucune ligne n'est créée/modifiée.

**Cause racine** : le contrôleur ESTAIR exige un payload de la forme `{ "element": { "name": "Foo", ... } }` (validateur ligne 96–98 et 590–593). Le frontend `composables/useEstair.ts:99-113` envoie le payload à plat : `{ "name": "Foo", ... }`. Mismatch contractuel jamais détecté.

**Code actuel — `create` (lignes 94–113)** :
```php
public function create(Request $request, $modulename)
{
    $validator = \Validator::make($request->all(), [
        'element' => 'required',
    ]);
    if ($validator->fails() || empty($modulename)) {
        return response()->json([
            'message' => $validator->errors()->first() . ' ou modulename manquant',
            'success' => false,
        ], 400);
    }
    $modulename = strtolower($modulename);
    $element    = $request->element;
    if (empty($element) || !is_array($element)) {
        return response()->json([
            'message' => 'Paramètres invalides. Un tableau d\'éléments est requis.',
            'success' => false,
        ], 400);
    }
    // ...
}
```

**Code actuel — `updateOne` (lignes 588–610)** :
```php
public function updateOne(Request $request, $moduleName, $uuid)
{
    $validator = \Validator::make($request->all(), [
        'element' => 'required',
    ]);
    if ($validator->fails()) {
        return response()->json([
            'message' => $validator->errors()->first(),
            'success' => false,
        ], 400);
    }
    $isOk       = false;
    $element    = $request->element;
    if (empty($element) || empty($uuid)) {
        return response()->json([
            'message' => 'Paramètres invalides. Un élément avec un UUID est requis.',
            'success' => false,
        ], 400);
    }
    // ...
}
```

**Décision de fix** : aligner le **backend** sur le contrat frontend déjà déployé (payload à plat), avec rétrocompatibilité pour d'anciens consommateurs qui enverraient encore `{ element: ... }`. C'est plus permissif et évite de modifier le frontend pour un comportement plus standard REST.

**Fix — `create` (lignes 94–153)** :

```php
public function create(Request $request, $modulename)
{
    if (empty($modulename)) {
        return response()->json([
            'message' => 'modulename manquant',
            'success' => false,
        ], 400);
    }
    $modulename = strtolower($modulename);

    // Accepter soit { element: {...} } (ancien contrat), soit {...} à plat (nouveau contrat).
    // Préférer le wrapper s'il est présent, sinon construire l'élément depuis les champs racines.
    $element = $request->input('element');
    if (!is_array($element)) {
        // Payload à plat : prendre tous les champs sauf les meta-paramètres internes.
        $reserved = [
            '_account_scope_id',
            '_account_scope_bypass',
            'identity_role',
            'identity_token',
        ];
        $element = collect($request->all())->except($reserved)->all();
    }

    if (empty($element) || !is_array($element)) {
        return response()->json([
            'message' => 'Aucune donnée fournie pour la création.',
            'success' => false,
        ], 400);
    }

    $isOk = false;
    $element['uuid'] = \Str::uuid();

    // Injection automatique de l'account_id pour les tables tenant-scoped
    if (EnforceAccountScope::tableHasAccountId($modulename)) {
        $bypass = $request->input('_account_scope_bypass', false);
        $accountId = $request->input('_account_scope_id');
        if (!$bypass && $accountId) {
            $element['account_id'] = $accountId;
        } elseif (!$bypass && empty($element['account_id'])) {
            return response()->json([
                'message' => 'account_id requis pour cette opération.',
                'success' => false,
            ], 403);
        }
    }

    // Pour les modèles avec HasGeneratedFields, laisser le name vide pour que le trait le génère
    $modelsWithAutoGeneratedFields = ['financial_requests', 'mouvements', 'etatsdeventes'];

    if (in_array($modulename, $modelsWithAutoGeneratedFields, true)) {
        $element['name'] = null;
    } else {
        $element['name'] = $this->generateName($modulename, $element);
    }

    $result = $this->boService->create($modulename, $element, $isOk);
    if ($isOk) {
        $result = (array) $result['record'];
        $this->dispatchBusinessEvent($modulename, 'created', $result, $request);
        return response()->json([
            'success' => true,
            'message' => 'Création réussie',
            'record' => $result,
        ], 201);
    }
    return response()->json([
        'success' => false,
        'error' => $result,
    ], 200);
}
```

**Fix — `updateOne` (lignes 588–660)** :

```php
public function updateOne(Request $request, $moduleName, $uuid)
{
    if (empty($uuid)) {
        return response()->json([
            'message' => 'UUID manquant',
            'success' => false,
        ], 400);
    }

    // Accepter soit { element: {...} }, soit {...} à plat (cf. E-05 create)
    $element = $request->input('element');
    if (!is_array($element)) {
        $reserved = [
            '_account_scope_id',
            '_account_scope_bypass',
            'identity_role',
            'identity_token',
        ];
        $element = collect($request->all())->except($reserved)->all();
    }

    if (empty($element) || !is_array($element)) {
        return response()->json([
            'message' => 'Aucune donnée fournie pour la mise à jour.',
            'success' => false,
        ], 400);
    }

    // Verification isolation multi-tenant avant mise a jour
    $moduleNameLower = strtolower($moduleName);
    if (EnforceAccountScope::tableHasAccountId($moduleNameLower)) {
        $existing = DB::table($moduleNameLower)->where('uuid', $uuid)->first();
        if (!$existing) {
            return response()->json([
                'message' => 'Objet non trouvé',
                'success' => false,
            ], 404);
        }
        if (!$this->verifyAccountOwnership($request, $moduleNameLower, $existing)) {
            return response()->json([
                'message' => 'Objet non trouvé',
                'success' => false,
            ], 404);
        }
    }

    $filteredElement = array_filter($element, function ($value, $key) {
        return $value !== '0000-00-00 00:00:00' && $key !== 'created_at';
    }, ARRAY_FILTER_USE_BOTH);

    // Empêcher la modification de account_id et de l'UUID
    unset($filteredElement['account_id']);
    unset($filteredElement['id']);

    $filteredElement['updated_at'] = now();
    $filteredElement['uuid'] = $uuid;

    $isOk = false;
    $result = $this->boService->update($moduleNameLower, $filteredElement, $isOk);

    if (is_string($result)) {
        return response()->json([
            'message' => 'La mise à jour a échoué.',
            'success' => false,
            'error' => $result,
        ], 200);
    }

    if (!($result['isOk'] ?? false)) {
        return response()->json([
            'message' => 'La mise à jour a échoué.',
            'success' => false,
            'error' => $result,
        ], 200);
    }

    $result = (array) $result['record'];
    $this->dispatchBusinessEvent($moduleNameLower, 'updated', $result, $request);

    return response()->json([
        'success' => true,
        'message' => 'Mise à jour réussie',
        'record' => $result,
    ], 200);
}
```

**Notes complémentaires sur le fix** :
- Au passage, `updateOne` utilisait `$moduleName` (mixed case) puis `$moduleNameLower` à différents endroits. La version corrigée utilise `$moduleNameLower` partout, plus cohérent.
- L'`unset($filteredElement['id'])` empêche un client malveillant d'essayer de réassigner l'`id` numérique.
- Le 404 sur "objet non trouvé" est plus correct sémantiquement que 200 (le code actuel retourne 200 avec `success: false`, ce qui désoriente les consommateurs HTTP standards).

**Test** :
1. Démarrer ESTAIR + frontend, se connecter
2. Ouvrir le module `accounts` → bouton "Nouveau" → remplir le formulaire → cliquer "Enregistrer"
3. **Attendu** : 201 retourné, ligne ajoutée à la liste, panel se ferme
4. Sélectionner la ligne → bouton "Modifier" → changer `status` → "Enregistrer"
5. **Attendu** : 200 retourné, ligne mise à jour, statut change visuellement
6. **Test rétrocompat** : `curl -X POST .../v1/admin/accounts -d '{"element":{"name":"X"}}' -H "Authorization: Bearer ..."` doit aussi retourner 201 (validation du fallback `element` wrapper)

**Dépendance** : aucune. À traiter avant tout le reste de l'audit (P0 absolue).

---

## E-06 — `deleteOne` cherche par `id` numérique alors que la route fournit un UUID

**Sévérité** : **Critical** (P0 ABSOLUE — bloque la suppression dans tout le système)
**Fichier** : `app/Http/Controllers/api/v1/CrudController.php` + `app/Librairies/BoService.php`
**Lignes** : `CrudController.php:662–702`, `BoService.php:479–537`

**Symptôme** : l'utilisateur clique sur Supprimer, confirme la dialog → message d'erreur "La suppression a échoué" ou silence. L'élément reste en liste.

**Cause racine** : la route est `Route::delete('/{modulename}/{uuid}', 'deleteOne')` — le 3e paramètre du contrôleur reçoit donc un UUID. Mais le code l'utilise comme un id numérique :

**Code actuel — `CrudController.php:662–702`** :
```php
public function deleteOne(Request $request, $moduleName, $id)
{
    $isOk = false;
    $moduleName = strtolower($moduleName);

    if (empty($id)) {
        return response()->json([...], 200);
    }

    if (EnforceAccountScope::tableHasAccountId($moduleName)) {
        $existing = DB::table($moduleName)->where('id', $id)->first();
        //                                       ^^^^ devrait être 'uuid'
        if ($existing && !$this->verifyAccountOwnership($request, $moduleName, $existing)) {
            return response()->json([
                'message' => 'Objet non trouvé',
                'success' => false,
            ], 200);
        }
    }

    $result = $this->boService->deleteId($moduleName, $id, $isOk);
    // ...
}
```

**Code actuel — `BoService.php:479-537`** :
```php
public function deleteId($objectName, $objectId, $cascade = false, &$isOk = false)
{
    // ...
    $deleted = DB::table(strtolower($objectName))
        ->where('id', $objectId)   // ← compare bigint à un UUID string → 0 match
        ->update(['is_deleted' => 1]);
    // ...
}
```

MySQL en mode non-strict caste silencieusement le UUID en `0` lors de la comparaison à un bigint, donc `where('id', 0)` ne matche aucune ligne (à moins qu'il existe un id=0). En mode strict, c'est une erreur SQL.

**Fix — `CrudController::deleteOne`** :

```php
public function deleteOne(Request $request, $moduleName, $uuid)
{
    $moduleName = strtolower($moduleName);

    if (empty($uuid)) {
        return response()->json([
            'message' => 'UUID manquant',
            'success' => false,
        ], 400);
    }

    // Vérification isolation multi-tenant avant suppression
    if (EnforceAccountScope::tableHasAccountId($moduleName)) {
        $existing = DB::table($moduleName)->where('uuid', $uuid)->first();
        if (!$existing) {
            return response()->json([
                'message' => 'Objet non trouvé',
                'success' => false,
            ], 404);
        }
        if (!$this->verifyAccountOwnership($request, $moduleName, $existing)) {
            return response()->json([
                'message' => 'Objet non trouvé',
                'success' => false,
            ], 404);
        }
    }

    $isOk = false;
    $result = $this->boService->deleteByUuid($moduleName, $uuid, $isOk);

    if (!$isOk) {
        return response()->json([
            'message' => 'La suppression a échoué.',
            'success' => false,
            'error' => $result,
        ], 200);
    }

    $this->dispatchBusinessEvent($moduleName, 'deleted', ['uuid' => $uuid], $request);

    return response()->json([
        'message' => 'Suppression réussie de l\'élément.',
        'success' => true,
    ], 200);
}
```

**Fix — `BoService` : nouvelle méthode `deleteByUuid`** (à ajouter à côté de `deleteId`, qui reste pour la cascade interne par numeric id) :

```php
/**
 * Soft-delete par UUID. Méthode principale appelée par les opérations CRUD
 * frontend où l'UUID est l'identifiant exposé.
 *
 * Le `deleteId` historique reste pour les usages internes nécessitant
 * un id numérique (cascades, batch internes).
 *
 * @param  string  $objectName  Nom de la table (sera lowercased)
 * @param  string  $uuid        UUID de la ligne à soft-delete
 * @param  bool    $isOk        Out: true si la suppression a réussi
 * @return string  Message de résultat (succès ou erreur)
 */
public function deleteByUuid($objectName, $uuid, &$isOk = false): string
{
    $isOk = false;
    if (empty($uuid)) {
        return "UUID manquant";
    }

    try {
        DB::beginTransaction();

        $deleted = DB::table(strtolower($objectName))
            ->where('uuid', $uuid)
            ->update([
                'is_deleted' => 1,
                'updated_at' => now(),
            ]);

        if ($deleted > 0) {
            DB::commit();
            $isOk = true;
            return "Suppression réussie";
        }

        DB::rollBack();
        return "Aucun enregistrement trouvé";
    } catch (\Illuminate\Database\QueryException $e) {
        DB::rollBack();
        if ($e->getCode() == 23000) {
            return 'Erreur de contrainte d\'intégrité : ' . $e->getMessage();
        }
        return 'Erreur SQL : ' . $e->getMessage();
    } catch (\Exception $e) {
        DB::rollBack();
        return 'Une erreur est survenue : ' . $e->getMessage();
    }
}
```

**Note importante** : `deleteId` n'est PAS supprimée. Elle reste utilisée par `delete` (mass) et par les opérations internes de cascade. C'est `deleteByUuid` qui devient le point d'entrée pour les suppressions individuelles depuis l'UI.

**Test** :
1. Frontend, module `accounts` → cliquer Supprimer sur une ligne → confirmer
2. **Attendu** : ligne disparaît de la liste
3. En DB : `SELECT is_deleted FROM accounts WHERE uuid = '<uuid>'` → renvoie `1`
4. La requête `GET /v1/admin/accounts` ne renvoie plus la ligne (filtré par `is_deleted = 0`)

**Dépendance** : aucune. P0 absolue, à traiter avec E-05.

---

## E-07 — `findByField` route mismatch entre frontend et backend

**Sévérité** : Medium (silencieux car pris dans un catch)
**Fichier** : `composables/useEstair.ts` (frontend) + `routes/api.php` (backend, info)
**Lignes** : `useEstair.ts:70-77` et `useEstair.ts:143-155`

**Symptôme** : la résolution `id numérique → UUID` (utilisée en fallback dans `fetchOne`) tombe systématiquement dans le catch silencieux. La fonction publique `findBy()` du composable retourne toujours `[]`. Pas d'erreur visible, mais la fonctionnalité ne marche pas.

**Cause racine** : le frontend appelle `POST /estair/admin/{name}/find-by` avec body `{ field, value }`. Le backend ESTAIR expose `GET /admin/{modulename}/find/{field}/{value}` (path params) — pas la même méthode HTTP, pas le même chemin.

**Routes ESTAIR existantes** (`routes/api.php:555-556`) :
```php
Route::get('/{modulename}/find/{field}/{value}', [CrudController::class, 'findByField']);
Route::get('/{modulename}/find/{field}', [CrudController::class, 'findByField']);
```

**Décision** : aligner le **frontend** sur la route GET existante (plus standard REST, pas de body sur GET).

**Fix — `composables/useEstair.ts:143-155`** :

```ts
async function findBy(field: string, value: any): Promise<T[]> {
  const name = toValue(moduleName)
  if (!name) return []
  try {
    const result = await $api<any>(
      `/estair/admin/${name}/find/${encodeURIComponent(field)}/${encodeURIComponent(String(value))}`,
    )
    // Le backend retourne soit { data: {...} } (single), soit { data: [...] } (multiple),
    // soit directement un objet/tableau. Normaliser en tableau pour la cohérence du composable.
    if (Array.isArray(result)) return result as T[]
    if (result && typeof result === 'object') {
      const data = (result as any).data
      if (Array.isArray(data)) return data as T[]
      if (data && typeof data === 'object') return [data as T]
    }
    return []
  }
  catch {
    return []
  }
}
```

**Fix — `composables/useEstair.ts:70-77`** (fallback id→uuid dans `fetchOne`) :

```ts
async function fetchOne(id: number | string): Promise<DetailRecord<T> | null> {
  const name = toValue(moduleName)
  if (!name || name.includes('.')) return null

  let resolvedId: string | number = id
  if (typeof id === 'number' || /^\d+$/.test(String(id))) {
    try {
      const result = await $api<any>(
        `/estair/admin/${name}/find/id/${Number(id)}`,
      )
      // result peut être l'objet record direct, ou { data: record }
      const record = result?.data && typeof result.data === 'object' && !Array.isArray(result.data)
        ? result.data
        : (Array.isArray(result?.data) ? result.data[0] : result)
      if (record && record.uuid) {
        resolvedId = record.uuid
      }
    }
    catch {
      // Fallback : tenter avec l'id original
    }
  }

  // ... suite inchangée
}
```

**Test** :
1. `useEstair('accounts').findBy('email', 'test@example.com')` → renvoie le ou les records correspondants
2. `useEstair('accounts').fetchOne(42)` (id numérique) → résout vers UUID puis renvoie le record complet

**Dépendance** : aucune.

---

## E-08 — `update` (mass) : variable `$uuid` undefined et flux fragile

**Sévérité** : Medium (le frontend ne l'appelle pas aujourd'hui, mais la route est exposée — et bugguée)
**Fichier** : `app/Http/Controllers/api/v1/CrudController.php`
**Lignes** : 494–586 (focus sur 527–578)

**Symptôme** : si un client appelle `POST /v1/admin/{module}/edit` ou `PUT /v1/admin/{module}` avec `ids: [1, 2, 3]` (scalars numériques), le backend lève une PHP notice "Undefined variable $uuid" et tombe en échec silencieux (boService::update reçoit `uuid: null`, ne trouve aucun record).

**Cause racine** : le code lignes 537–540 ne définit `$uuid` que si `is_array($id)` est vrai. Pour les `ids` scalars, `$uuid` reste indéfini, puis `$nonNullElements['uuid'] = $uuid;` ligne 557 stocke `null`.

**Code actuel — extrait des lignes 527–578** :
```php
foreach ($ids as $id) {
    if (empty($id)) {
        $errors[] = ['element' => $id, 'message' => 'ID manquant pour cet élément'];
        continue;
    }

    // Get id if it's an array
    if(is_array($id)){
        $uuid = $id['uuid'];
        $id = $id['id'];
    }

    $record = \DB::table($moduleName)->where('id', $id)->first();
    if (!$record) {
        $errors[] = ['id' => $id, 'message' => 'Enregistrement introuvable'];
        continue;
    }

    try {
        $nonNullElements = array_filter($elements, function ($value) {
            return !is_null($value);
        });
        $nonNullElements['id'] = $id;
        $nonNullElements['uuid'] = $uuid;  // ← undefined si $id était scalar
        $nonNullElements['updated_at'] = now();
        // ...
```

**Fix — refonte complète de la boucle 527–578** :

```php
$errors  = [];
$records = [];

foreach ($ids as $idEntry) {
    if (empty($idEntry)) {
        $errors[] = ['element' => null, 'message' => 'Identifiant manquant'];
        continue;
    }

    // Normalisation : accepte 3 formats d'identifiant
    //   - scalar numeric  : id de la ligne
    //   - scalar string   : UUID de la ligne
    //   - array           : { id: ..., uuid: ... }
    $id = null;
    $uuid = null;

    if (is_array($idEntry)) {
        $id   = $idEntry['id']   ?? null;
        $uuid = $idEntry['uuid'] ?? null;
    } elseif (is_numeric($idEntry)) {
        $id = (int) $idEntry;
    } else {
        $uuid = (string) $idEntry;
    }

    // Charger le record en privilégiant uuid (type-safe : pas de cast bigint↔string)
    $record = null;
    if ($uuid) {
        $record = DB::table($moduleName)->where('uuid', $uuid)->first();
    } elseif ($id) {
        $record = DB::table($moduleName)->where('id', $id)->first();
        if ($record) {
            $uuid = $record->uuid;
        }
    }

    if (!$record) {
        $errors[] = [
            'identifier' => $idEntry,
            'message' => 'Enregistrement introuvable',
        ];
        continue;
    }

    // Vérification multi-tenant (mêmes règles que updateOne)
    if (EnforceAccountScope::tableHasAccountId($moduleName)) {
        if (!$this->verifyAccountOwnership($request, $moduleName, $record)) {
            $errors[] = [
                'uuid' => $uuid,
                'message' => 'Objet hors scope',
            ];
            continue;
        }
    }

    try {
        $nonNullElements = array_filter($elements, fn ($v) => !is_null($v));

        // Empêcher modification d'identifiants
        unset($nonNullElements['id']);
        unset($nonNullElements['account_id']);

        $nonNullElements['uuid'] = $uuid;
        $nonNullElements['updated_at'] = now();

        $isOk = false;
        $result = $this->boService->update($moduleName, $nonNullElements, $isOk);

        if (!$isOk || (is_array($result) && !($result['isOk'] ?? false))) {
            $errors[] = [
                'uuid' => $uuid,
                'message' => is_string($result) ? $result : 'Échec de la mise à jour',
            ];
            continue;
        }

        if (isset($result['record'])) {
            $records[] = $result['record'];
        }
    } catch (\Exception $e) {
        $errors[] = [
            'uuid' => $uuid,
            'message' => $e->getMessage(),
        ];
    }
}

return response()->json([
    'message' => empty($errors) ? 'Toutes les mises à jour ont été effectuées.' : 'Certaines mises à jour ont échoué.',
    'success' => empty($errors),
    'records' => $records,
    'errors' => $errors,
], empty($errors) ? 200 : 207);
```

**Test** :
1. `PUT /v1/admin/accounts` body `{ "ids": ["uuid-1","uuid-2"], "data": { "status": "Inactif" } }` → 200, deux records mis à jour
2. `PUT /v1/admin/accounts` body `{ "ids": [{"id":1,"uuid":"u1"}, {"id":2,"uuid":"u2"}], "data": { "status": "Inactif" } }` → 200 (rétro-compat)
3. `PUT /v1/admin/accounts` body `{ "ids": [99999], "data": {...} }` (id introuvable) → 207 avec `errors` peuplé

**Dépendance** : E-06 (méthode `verifyAccountOwnership` reste utilisée).

---

## E-09 — `delete` (mass) : `modulename` mal lu et signature incomplète

**Sévérité** : Medium (idem E-08, exposé mais non utilisé par le frontend)
**Fichier** : `app/Http/Controllers/api/v1/CrudController.php`
**Lignes** : 704–748

**Symptôme** : appel à `POST /v1/admin/{module}/delete` ne supprime rien et peut générer une erreur PHP "table name cannot be null".

**Cause racine** : la signature `delete(Request $request)` ne reçoit pas `{modulename}` du path (Laravel binde positionnellement). Le code tente de le lire depuis `$request->modulename` (= body) où il n'est jamais envoyé. `$moduleName` est null, et `BoService::deleteId(null, ...)` part sur une table inexistante.

**Code actuel** :
```php
public function delete(Request $request)
{
    $isOk = false;
    $ids = $request->input('ids');
    $moduleName = $request->modulename;   // ← null car pas dans le body
    // ...
    $result = $this->boService->deleteId($moduleName, $id, $cascade, $isOk);
    // ...
}
```

**Fix — refonte complète de la méthode** :

```php
public function delete(Request $request, $modulename)
{
    $moduleName = strtolower($modulename);
    $ids = $request->input('ids');

    if (empty($ids) || !is_array($ids)) {
        return response()->json([
            'message' => 'Paramètres invalides. Un tableau d\'IDs/UUIDs est requis.',
            'success' => false,
            'modulename' => $moduleName,
        ], 400);
    }

    $errors = [];
    foreach ($ids as $entry) {
        // Normalise : scalar numeric (id), string (uuid), ou { id, uuid }
        $uuid = null;

        if (is_array($entry)) {
            $uuid = $entry['uuid'] ?? null;
            // Fallback : résoudre id → uuid si pas d'uuid fourni
            if (!$uuid && isset($entry['id'])) {
                $row = DB::table($moduleName)->where('id', (int) $entry['id'])->first(['uuid']);
                $uuid = $row?->uuid;
            }
        } elseif (is_string($entry) && !is_numeric($entry)) {
            $uuid = $entry;
        } elseif (is_numeric($entry)) {
            $row = DB::table($moduleName)->where('id', (int) $entry)->first(['uuid']);
            $uuid = $row?->uuid;
        }

        if (!$uuid) {
            $errors[] = ['entry' => $entry, 'message' => 'UUID introuvable'];
            continue;
        }

        // Vérification multi-tenant
        if (EnforceAccountScope::tableHasAccountId($moduleName)) {
            $existing = DB::table($moduleName)->where('uuid', $uuid)->first();
            if (!$existing || !$this->verifyAccountOwnership($request, $moduleName, $existing)) {
                $errors[] = ['uuid' => $uuid, 'message' => 'Objet non trouvé ou hors scope'];
                continue;
            }
        }

        $isOk = false;
        $result = $this->boService->deleteByUuid($moduleName, $uuid, $isOk);
        if (!$isOk) {
            $errors[] = ['uuid' => $uuid, 'message' => $result];
            continue;
        }

        $this->dispatchBusinessEvent($moduleName, 'deleted', ['uuid' => $uuid], $request);
    }

    if (!empty($errors)) {
        return response()->json([
            'message' => 'Certaines suppressions ont échoué.',
            'success' => false,
            'errors' => $errors,
        ], 207);
    }

    return response()->json([
        'message' => 'Suppression réussie de tous les enregistrements.',
        'success' => true,
    ], 200);
}
```

**Test** :
1. `POST /v1/admin/accounts/delete` body `{ "ids": ["uuid-1","uuid-2"] }` → 200
2. `POST /v1/admin/accounts/delete` body `{ "ids": [1, 2] }` (numeric) → 200, ids résolus en UUID puis supprimés
3. `POST /v1/admin/accounts/delete` body `{ "ids": ["bad-uuid"] }` → 207 avec error
4. Vérifier en DB que les lignes ont `is_deleted = 1`

**Dépendance** : E-06 (utilise `deleteByUuid`).

---

# 3. CHAPS Backend (Laravel)

## C-01 — Vue `update` non filtrée par `update=1`

**Sévérité** : High
**Fichier** : `app/Http/Controllers/MetadataController.php`
**Lignes** : 674–676 (commenté)

**Symptôme** : tous les champs metadata apparaissent dans le formulaire de modification, y compris ceux marqués `update=0` par l'admin.

**Cause racine** : la branche est commentée (avec capitalisation incorrecte `Update` qui ne matchait pas la colonne SQL `update`).

**Code actuel** :
```php
if (isset($field['create']) && $field['create'] === 1) {
    $createViewFields[] = $fieldData;
}
if (isset($field['header']) && $field['header'] === 1) {
    $headerViewFields[] = $fieldData;
}
// if (isset($field['Update']) && $field['Update'] === 1) {
//     $updateViewFields[] = $fieldData;
// }
if (isset($field['list']) && $field['list'] === 1) {
    $listeViewFields[] = $fieldData;
}
```

**Fix** :
```php
if (isset($field['create']) && (int) $field['create'] === 1) {
    $createViewFields[] = $fieldData;
}
if (isset($field['header']) && (int) $field['header'] === 1) {
    $headerViewFields[] = $fieldData;
}
if (isset($field['update']) && (int) $field['update'] === 1) {
    $updateViewFields[] = $fieldData;
}
if (isset($field['list']) && (int) $field['list'] === 1) {
    $listeViewFields[] = $fieldData;
}
```

Cast en `int` ajouté également pour les autres branches : selon la colonne SQL `tinyint`, la valeur peut être `1` (int) ou `'1'` (string) selon le driver, le `===` strict casserait. Le cast garantit l'égalité correcte.

**Test** :
1. Marquer un field avec `update=0` en CHAPS DB
2. `GET /v1/metadata/{module}` ne le retourne pas dans `UpdateView.fields`
3. Le frontend en mode édition ne propose pas ce champ

**Dépendance** : aucune.

---

## C-02 — `$field['enum'] == 1` fragile sur varchar(45)

**Sévérité** : Medium
**Fichier** : `app/Http/Controllers/MetadataController.php`
**Lignes** : 660

**Symptôme** : seule la valeur exacte `'1'` (string) ou `1` (int) déclenche le mode picklist. Toute autre valeur (`'true'`, `'enum'`, `'multi'`, `'badge'`) passe en mode `string`.

**Cause racine** : la colonne `metadatas.enum` est `varchar(45)`, mais utilisée comme un booléen. Hybride mal défini.

**Décision proposée** : à terme, refactor en deux colonnes — `is_enum tinyint(1)` + `enum_kind enum('single','multi','badge')`. Pour le court terme, sécuriser la lecture :

**Code actuel** :
```php
if ($field['enum'] == 1) {
    $fieldData['options'] = $this->getEnums($field, $module);
    $fieldData['type'] = 'picklist';
}
```

**Fix court terme** :
```php
// Acceptation tolérante : 1, '1', 'true', 'yes', tout type tronqué non vide considéré actif.
// L'extension future pourrait pivoter sur enum_kind si on ajoute la colonne.
$enumFlag = $field['enum'] ?? null;
$isEnum = in_array($enumFlag, [1, '1', 'true', 'yes', 'enum'], true)
       || (is_string($enumFlag) && $enumFlag !== '0' && $enumFlag !== '');

if ($isEnum) {
    $fieldData['options'] = $this->getEnums($field, $module);
    $fieldData['type'] = 'picklist';
} else {
    $fieldData['type'] = $field['type'] ?? 'string';
}
```

**Migration future (P3)** :
```sql
ALTER TABLE metadatas
  CHANGE COLUMN enum is_enum tinyint(1) DEFAULT 0,
  ADD COLUMN enum_kind ENUM('single','multi','badge') DEFAULT 'single' AFTER is_enum;
UPDATE metadatas SET is_enum = 1 WHERE is_enum IS NOT NULL AND is_enum != '0';
```

**Test** : metadata avec `enum = '1'`, `enum = 1`, `enum = 'true'` → tous traités comme picklist.

**Dépendance** : aucune.

---

## C-03 — `usort` returnant un booléen (PHP 8+)

**Sévérité** : Medium
**Fichier** : `app/Http/Controllers/MetadataController.php`
**Lignes** : 682–694

**Symptôme** : tris instables et avertissement deprecation sous PHP 8+ (à terme erreur en PHP 9).

**Code actuel** :
```php
usort($createViewFields, function ($x, $y) {
    return $x['sequencecreate'] > $y['sequencecreate'];   // retourne bool
});
usort($detailViewFields, function ($x, $y) {
    return $x['sequencedetail'] > $y['sequencedetail'];
});
usort($listeViewFields, function ($x, $y) {
    return $x['sequencelist'] > $y['sequencelist'];
});
usort($updateViewFields, function ($x, $y) {
    return $x['sequenceupdate'] > $y['sequenceupdate'];
});
```

**Fix** : utiliser `<=>` (spaceship operator) qui retourne -1/0/1 :

```php
usort($createViewFields, fn($x, $y) =>
    ($x['sequencecreate'] ?? 0) <=> ($y['sequencecreate'] ?? 0)
);
usort($detailViewFields, fn($x, $y) =>
    ($x['sequencedetail'] ?? 0) <=> ($y['sequencedetail'] ?? 0)
);
usort($listeViewFields, fn($x, $y) =>
    ($x['sequencelist'] ?? 0) <=> ($y['sequencelist'] ?? 0)
);
usort($updateViewFields, fn($x, $y) =>
    ($x['sequenceupdate'] ?? 0) <=> ($y['sequenceupdate'] ?? 0)
);
```

Ajouter aussi pour `headerViewFields` (cf. C-04) :
```php
usort($headerViewFields, fn($x, $y) =>
    ($x['sequencedetail'] ?? 0) <=> ($y['sequencedetail'] ?? 0)
);
```

**Test** : `php -d display_errors=1 artisan tinker` puis appeler manuellement la méthode → aucun warning deprecation.

**Dépendance** : aucune.

---

## C-04 — `headerViewFields` non trié

**Sévérité** : Medium
**Fichier** : `app/Http/Controllers/MetadataController.php`
**Lignes** : autour de 671–695

**Symptôme** : les chips header dans le panneau detail apparaissent dans un ordre arbitraire (dépendant de l'ordre SQL).

**Cause racine** : pas d'appel `usort` sur `$headerViewFields`.

**Fix** : voir C-03 ci-dessus (intégré).

**Test** : modifier `sequencedetail` de plusieurs fields `header=1` ; vérifier l'ordre dans `DetailView.headers`.

**Dépendance** : C-03.

---

## C-05 — `ctas_enum` à étendre pour les CTAs métier

**Sévérité** : High
**Fichier** : DB CHAPS — `ctas_enum` table
+ logique côté `app/Services/PermissionService.php` si besoin de mapper

**Symptôme** : impossible d'afficher des boutons métier (issue_pnr, void_ticket, refund_ticket, ...) dans le frontend tant qu'ils ne sont pas déclarés dans `ctas_enum`.

**Cause racine** : `ctas_enum` ne contient que les 9 actions CRUD canoniques. Les actions métier issues de `ESTAIR::getAvailableActions` n'y figurent pas.

**Fix** : seed SQL à exécuter en migration CHAPS :

```sql
-- Migration : 2026_05_09_extend_ctas_enum_for_workflow_actions.php
-- Étend ctas_enum avec les CTAs métier pour aligner Pipeline B et Pipeline C.

INSERT INTO ctas_enum (uuid, name, label, action, icon, view, is_active, sequence, roles, created_at, updated_at) VALUES
  -- PNR workflow
  (UUID(), 'link_detail', 'Lier au compte', 'link', 'icon-link', 'detail', 1, 20,
   '["admin","support","sale","agency","agent"]', NOW(), NOW()),
  (UUID(), 'pricing_detail', 'Calculer le tarif', 'pricing', 'icon-calculator', 'detail', 1, 21,
   '["admin","support","sale","agency","agent"]', NOW(), NOW()),
  (UUID(), 'confirm_pricing_detail', 'Confirmer le tarif', 'confirm_pricing', 'icon-check', 'detail', 1, 22,
   '["admin","support","sale","agency","agent"]', NOW(), NOW()),
  (UUID(), 'issue_pnr_detail', 'Émettre le billet', 'issue_pnr', 'icon-send', 'detail', 1, 23,
   '["admin","support","sale","agency","agent"]', NOW(), NOW()),

  -- Etats de ventes workflow
  (UUID(), 'void_ticket_detail', 'Annuler le billet', 'void_ticket', 'icon-x', 'detail', 1, 30,
   '["admin","support","sale","finance"]', NOW(), NOW()),
  (UUID(), 'refund_ticket_detail', 'Rembourser le billet', 'refund_ticket', 'icon-arrow-back', 'detail', 1, 31,
   '["admin","support","sale","finance"]', NOW(), NOW()),
  (UUID(), 'reissue_ticket_detail', 'Réémettre le billet', 'reissue_ticket', 'icon-refresh', 'detail', 1, 32,
   '["admin","support","sale","agency","agent"]', NOW(), NOW()),

  -- Accounts workflow
  (UUID(), 'office_id_detail', 'Vérifier l''Office ID', 'office_id', 'icon-shield-check', 'detail', 1, 40,
   '["admin","support","sale"]', NOW(), NOW())

ON DUPLICATE KEY UPDATE updated_at = NOW();
```

Puis pour chaque module concerné, créer les `ctas` correspondants (M:N entre modules et ctas_enum) :

```sql
-- Lier les CTAs workflow aux modules concernés
INSERT INTO ctas (uuid, name, ctas_enum_id, label, icon, is_active, modules_id, roles, created_at, updated_at)
SELECT
  UUID(),
  CONCAT(m.name, '_', ce.action, '_', ce.view),
  ce.id,
  ce.label,
  ce.icon,
  1,
  m.id,
  ce.roles,
  NOW(),
  NOW()
FROM modules m
JOIN ctas_enum ce ON ce.name IN ('link_detail','pricing_detail','confirm_pricing_detail','issue_pnr_detail')
WHERE m.name IN ('pnr','pnrs')
ON DUPLICATE KEY UPDATE updated_at = NOW();

INSERT INTO ctas (uuid, name, ctas_enum_id, label, icon, is_active, modules_id, roles, created_at, updated_at)
SELECT
  UUID(),
  CONCAT(m.name, '_', ce.action, '_', ce.view),
  ce.id,
  ce.label,
  ce.icon,
  1,
  m.id,
  ce.roles,
  NOW(),
  NOW()
FROM modules m
JOIN ctas_enum ce ON ce.name IN ('void_ticket_detail','refund_ticket_detail','reissue_ticket_detail')
WHERE m.name = 'etatsdeventes'
ON DUPLICATE KEY UPDATE updated_at = NOW();

INSERT INTO ctas (uuid, name, ctas_enum_id, label, icon, is_active, modules_id, roles, created_at, updated_at)
SELECT
  UUID(),
  CONCAT(m.name, '_', ce.action, '_', ce.view),
  ce.id,
  ce.label,
  ce.icon,
  1,
  m.id,
  ce.roles,
  NOW(),
  NOW()
FROM modules m
JOIN ctas_enum ce ON ce.name = 'office_id_detail'
WHERE m.name = 'accounts'
ON DUPLICATE KEY UPDATE updated_at = NOW();
```

**Note pour `PermissionService::ACTION_CTA_MAP`** : ces nouvelles actions n'entrent pas dans la matrice 9-actions canonique. C'est OK : `permissions` reste sur les 9 actions de base, et les CTAs workflow sont consommées via `cta` directement par le frontend (cf. F-03).

**Test** :
1. Après seed, `pages/admin/ctas.vue` doit afficher les nouvelles colonnes
2. `GET /v1/metadata/pnrs` (avec rôle `agent`) → `DetailView.cta` contient `link`, `pricing`, `confirm_pricing`, `issue_pnr`
3. Frontend : sur un PNR `'En attente'`, les 3 boutons apparaissent (intersection cta CHAPS × availableActions ESTAIR)

**Dépendance** : D-01 appliqué d'abord (rôles canoniques en place).

---

# 4. CHAPS Database — migrations data

## D-01 — Migration des rôles legacy vers les rôles canoniques

**Sévérité** : Critical
**Fichier** : SQL migration CHAPS
**Tables impactées** : `ctas`, `ctas_enum`, `menus`

**Symptôme** : tous les utilisateurs non-`super_admin`/`admin_agence` reçoivent une réponse vide (aucune CTA, aucun menu, aucune permission).

**Cause racine** : les colonnes JSON `roles` ne contiennent que `super_admin` (et parfois `admin_agence`).

**Fix** : migration SQL avec mapping explicite. **À exécuter sur staging avant prod.**

```sql
-- Migration : 2026_05_09_align_roles_to_canonical.php
-- Migre les rôles legacy de toutes les tables CHAPS vers la nomenclature canonique :
-- super_admin → admin
-- admin_agence → agency
-- (manager, comptable n'ont pas de données réelles dans la DB analysée, mais on les couvre)
-- manager → support
-- comptable → finance

-- Helper : remplace une valeur dans un JSON array
-- (Mariadb 10.6+ : JSON_VALUE_REPLACE; MySQL 8 : approximation via JSON_REPLACE/JSON_SEARCH)

-- TABLE: ctas_enum
UPDATE ctas_enum
SET roles = JSON_ARRAY('admin','support','sale','finance','agency','agent')
WHERE JSON_CONTAINS(roles, '"super_admin"') = 1
  AND JSON_LENGTH(roles) = 1;

-- Cas avec super_admin + admin_agence
UPDATE ctas_enum
SET roles = JSON_ARRAY('admin','agency')
WHERE JSON_CONTAINS(roles, '"super_admin"') = 1
  AND JSON_CONTAINS(roles, '"admin_agence"') = 1;

-- TABLE: ctas (même logique)
UPDATE ctas
SET roles = JSON_ARRAY('admin','support','sale','finance','agency','agent')
WHERE JSON_CONTAINS(roles, '"super_admin"') = 1
  AND JSON_LENGTH(roles) = 1;

UPDATE ctas
SET roles = JSON_ARRAY('admin','agency')
WHERE JSON_CONTAINS(roles, '"super_admin"') = 1
  AND JSON_CONTAINS(roles, '"admin_agence"') = 1;

-- TABLE: menus
UPDATE menus
SET roles = JSON_ARRAY('admin','support','sale','finance','agency','agent')
WHERE JSON_CONTAINS(roles, '"super_admin"') = 1
  AND JSON_LENGTH(roles) = 1;

UPDATE menus
SET roles = JSON_ARRAY('admin','agency')
WHERE JSON_CONTAINS(roles, '"super_admin"') = 1
  AND JSON_CONTAINS(roles, '"admin_agence"') = 1;
```

Puis affiner par module via la matrice du fichier Excel `Configuration_Keycloak_ESTAIR_-_Basée_sur_Excel.md`. Par exemple pour restreindre les CTAs `delete` du module `accounts` au seul rôle `admin` :

```sql
UPDATE ctas c
JOIN modules m ON m.id = c.modules_id
JOIN ctas_enum ce ON ce.id = c.ctas_enum_id
SET c.roles = JSON_ARRAY('admin')
WHERE m.name = 'accounts'
  AND ce.action = 'delete';
```

**Procédure de bascule** :
1. Snapshot DB CHAPS
2. Appliquer la migration sur staging
3. Tester avec un user de chaque rôle canonique
4. Affiner par module selon la matrice Excel
5. Migration Keycloak en parallèle (manuel par Nicephore)
6. Bascule prod

**Test** : pour chaque rôle canonique, `GET /v1/metadata/{module}` retourne `permissions.read = true` et au moins une CTA.

**Dépendance** : aucune côté CHAPS, mais doit être coordonné avec la migration Keycloak.

---

## D-02 — Ajout d'une colonne `label` à `picklists` (optionnel, P3)

**Sévérité** : Medium
**Fichier** : DB CHAPS
**Tables impactées** : `picklists`

**Symptôme** : aujourd'hui, `picklists.value` sert à la fois de valeur stockée ET de label affiché. Pour `etatsdeventes.status = 'issue'`, l'utilisateur voit le mot anglais "issue" — pas idéal.

**Cause racine** : pas de séparation entre la valeur littérale (technique, pour matching) et le libellé (UX, pour affichage).

**Fix** : migration ajoutant `label` :

```sql
ALTER TABLE picklists
  ADD COLUMN label VARCHAR(100) DEFAULT NULL AFTER value;

-- Backfill : par défaut, label = value (préserve le comportement actuel)
UPDATE picklists SET label = value WHERE label IS NULL;

-- Cas spécifiques traduits manuellement (exemples, à compléter)
UPDATE picklists SET label = 'Émission'    WHERE name = 'etatsdeventes_status_issue';
UPDATE picklists SET label = 'Réémission'  WHERE name = 'etatsdeventes_status_reissue';
UPDATE picklists SET label = 'Annulation'  WHERE name = 'etatsdeventes_status_void';
UPDATE picklists SET label = 'Remboursement' WHERE name = 'etatsdeventes_status_refund';
UPDATE picklists SET label = 'Réinstallation' WHERE name = 'etatsdeventes_status_reinstate';

UPDATE picklists SET label = 'Billet'      WHERE name = 'etatsdeventes_type_ticket';
UPDATE picklists SET label = 'EMD'         WHERE name = 'etatsdeventes_type_emd';
UPDATE picklists SET label = 'ADM'         WHERE name = 'etatsdeventes_type_adm';
UPDATE picklists SET label = 'ACM'         WHERE name = 'etatsdeventes_type_acm';
```

**Côté CHAPS — `MetadataController::getEnums()`** : exposer le label, la sérialisation se fait toute seule (Eloquent inclut tous les champs).

**Côté frontend — `useMetadata.normalizePicklistOptions()`** : le `label` retourné par CHAPS prend désormais priorité :
```ts
function normalizePicklistOptions(options: any[] | undefined): PicklistValue[] | undefined {
  if (!Array.isArray(options) || !options.length) return undefined
  return options.map(o => ({
    ...o,
    label: o.label ?? o.value ?? '',  // priorité au label CHAPS, sinon value
    color_font: o.color_font ?? o.color_front ?? undefined,
  }))
}
```

**Test** :
1. Picklist avec `value = 'issue'`, `label = 'Émission'`
2. Frontend affiche "Émission" sur le chip
3. Le matching côté liste/detail se fait toujours sur `option.value === 'issue'`

**Note** : à mettre en place P3, après stabilisation des autres patches. La traduction des picklists existantes est un travail continu, pas bloquant.

**Dépendance** : aucune.

---

# 5. Tâches transverses

## T-01 — Migration des rôles : checklist

Ordre d'exécution recommandé :

1. **Pré-bascule** : assurer que tous les utilisateurs Keycloak existants sont identifiés et que leur mapping legacy → canonique est connu.
2. **CHAPS DB** : appliquer D-01 sur staging, valider avec un user de chaque rôle canonique.
3. **CHAPS DB** : appliquer D-01 en prod.
4. **Keycloak** : ajouter les nouveaux rôles canoniques s'ils n'existent pas (`admin`, `support`, `sale`, `finance`, `agency`, `agent`, `viewer`). Réassigner chaque utilisateur. Désactiver les anciens rôles (`super_admin`, `admin_agence`, `manager`, `comptable`) sans les supprimer immédiatement.
5. **ESTAIR backend** : `app/Services/ModulePermissionService.php:120` — remplacer la liste hardcodée :
   ```php
   $roles = ['admin','support','sale','finance','agency','agent','viewer'];
   ```
6. **Frontend** : appliquer F-12 (suppression du mapping legacy).
7. **Période d'observation** : 1 semaine, monitor les 401/403.
8. **Suppression définitive** : retirer les anciens rôles de Keycloak, supprimer en CHAPS DB toute référence aux anciens rôles via `JSON_REMOVE`.

## T-02 — Extension `ctas_enum` pour CTAs métier

Voir C-05 (seed SQL des CTAs métier) + E-01 (mapping ESTAIR `getAvailableActions`).

Procédure :
1. C-05 partie 1 : seed `ctas_enum`
2. C-05 partie 2 : seed `ctas` par module
3. E-01 : mise à jour de `getAvailableActions()` pour retourner les noms canoniques `*_detail`
4. F-03 : déjà couvert par F-03 si appliqué

## T-03 — Idempotence du dispatch + scheduler

Voir E-04 (logique delta merge) + le scheduler Laravel quotidien.

Procédure :
1. E-04 côté ESTAIR (envoi schema-only)
2. E-04 côté CHAPS (réception delta-merge)
3. Scheduler quotidien
4. Test : modifier displayname côté CHAPS Admin, lancer dispatch manuel, vérifier préservation
5. Test : ajouter une colonne en DB ESTAIR, lancer dispatch, vérifier ajout
6. Test : retirer une colonne, vérifier archivage

---

# 6. Plan d'exécution recommandé

> **⚠️ Lecture importante** : E-05 et E-06 sont des P0 absolues qui bloquent toute écriture (création/édition/suppression) dans tout le système. Ils **doivent** être traités avant n'importe quel autre item de l'audit. Sans ces deux fixes, l'application n'est utilisable qu'en lecture seule.

| Sprint | Items | Durée | Bloque |
|---|---|---|---|
| **S0** (P0 ABSOLUE — écriture cassée) | **E-05, E-06, E-07** | **1/2 journée** | **toute l'écriture, demos, recette** |
| **S0bis** (préparation rôles) | D-01 staging, T-01 étapes 1-3 | 1 jour | autorisations runtime |
| **S1** (frontend foundations) | F-04, F-05, F-06, F-01, F-02 | 1 jour | F-03, F-11 |
| **S2** (frontend UI alignment) | F-03, F-11, F-13 | 1 jour | utilisable |
| **S3** (cleanup frontend) | F-07, F-08, F-09, F-10, F-15, F-14 | 1/2 jour | — |
| **S4** (CHAPS backend) | C-01, C-02, C-03, C-04 | 1/2 jour | — |
| **S5** (Pipeline B complet) | C-05 (seed CTAs métier) | 1/2 jour | E-01 |
| **S6** (ESTAIR alignment) | E-01, E-02, E-03, E-08, E-09 | 1 jour | tests bout-en-bout |
| **S7** (Keycloak + cleanup roles) | T-01 étapes 4-8, F-12 | 1 jour étalé | — |
| **S8** (idempotence dispatch) | E-04 (delta merge) | 1-2 jours | scheduler |
| **S9** (P3 optionnel) | D-02, C-02 migration | 1 jour | — |

**Total** : 8 à 10 jours de dev cumulés, étalables sur 2-3 semaines avec recette intermédiaire.

**Découpage minimal pour démo immédiate** : S0 + S1 + S2 = ~2.5 jours = écriture restaurée + autorisations CHAPS-driven + UI alignée. Le reste est de la dette technique à traiter ensuite.

---

# 7. Tests d'acceptation bout-en-bout

> **Ordre d'exécution recommandé** : Scénarios 0a–0d (CRUD de base, après S0) en tout premier, puis 1–5 (CTAs/permissions, après S2) puis 6 (idempotence dispatch, après S8). Les scénarios CRUD valident le socle technique ; sans eux, les autres scénarios sont impossibles à exécuter (puisqu'on ne peut pas créer les données de test).

### Scénario 0a — CREATE single (validation E-05)

**Préconditions** : E-05 appliqué. Utilisateur connecté en `admin` (ou rôle ayant CTA `create_list` sur `accounts` une fois Pipeline B en place).

1. Module `accounts` → bouton "Nouveau"
2. Remplir : `name = "Test Acceptance Create"`, `office_id = "TEST01"`, `estair_id = "TST"`, `status = "Actif"`, `type = "Corporate"`
3. Cliquer "Enregistrer"

**Attendu** :
- HTTP 201 sur `POST /v1/admin/accounts`
- Panel se ferme
- Liste rafraîchie, nouvelle ligne visible avec les valeurs saisies
- En DB ESTAIR : `SELECT id, uuid, name, status FROM accounts WHERE name = 'Test Acceptance Create'` retourne 1 ligne avec UUID généré et `is_deleted = 0`
- Le chip statut affiche le bon style (couleurs CHAPS)

**Cas d'erreur à tester** :
- Soumettre formulaire vide : 400 avec message « Aucune donnée fournie pour la création »
- Soumettre sans `office_id` (NOT NULL en DB) : erreur SQL relayée proprement, pas de 500 brut

---

### Scénario 0b — UPDATE single (validation E-05)

**Préconditions** : Scénario 0a exécuté (record existe).

1. Sélectionner la ligne créée → bouton "Modifier"
2. Changer `status` de `Actif` à `Inactif`
3. Cliquer "Enregistrer"

**Attendu** :
- HTTP 200 sur `PUT /v1/admin/accounts/{uuid}` avec body à plat `{ "status": "Inactif" }`
- Panel se ferme
- Liste affiche maintenant le statut "Inactif" avec le chip de couleur correspondant
- En DB : `SELECT status, updated_at FROM accounts WHERE uuid = '<uuid>'` → status = `Inactif`, updated_at récent

**Cas d'erreur** :
- Modifier un compte d'un autre `account_id` (multi-tenant) en injectant un UUID via curl : 404 "Objet non trouvé"
- Tenter de modifier l'`account_id` lui-même via le payload : ignoré (unset côté contrôleur)

---

### Scénario 0c — DELETE single (validation E-06)

**Préconditions** : record créé et en `is_deleted = 0`.

1. Sélectionner la ligne créée → bouton "Supprimer"
2. Confirmer dans la dialog

**Attendu** :
- HTTP 200 sur `DELETE /v1/admin/accounts/{uuid}`
- Panel se ferme
- Ligne disparaît de la liste
- En DB : `SELECT is_deleted, updated_at FROM accounts WHERE uuid = '<uuid>'` → `is_deleted = 1`, updated_at récent
- `GET /v1/admin/accounts` ne retourne plus cette ligne (filtre `is_deleted = 0`)

**Cas d'erreur** :
- DELETE sur un UUID inexistant : 404 "Objet non trouvé"
- DELETE sur une ligne déjà supprimée : 200 (idempotent côté soft-delete) ou 404 selon décision retenue (à expliciter dans tests unitaires)

---

### Scénario 0d — Find-by + fetchOne fallback (validation E-07)

**Préconditions** : E-07 appliqué. Au moins un account avec id numérique connu en DB.

```ts
// Test unitaire suggéré (vitest)
import { useEstair } from '@/composables/useEstair'

it('findBy retourne les records matchant', async () => {
  const { findBy } = useEstair('accounts')
  const result = await findBy('email', 'admin@example.com')
  expect(result.length).toBeGreaterThan(0)
  expect(result[0]).toHaveProperty('uuid')
})

it('fetchOne avec id numérique résout en UUID', async () => {
  const { fetchOne } = useEstair('accounts')
  const record = await fetchOne(1)
  expect(record).not.toBeNull()
  expect(record?.data).toHaveProperty('uuid')
})
```

**Attendu** :
- `findBy('email', '...')` renvoie les records correspondants (tableau non vide)
- `fetchOne(1)` (id numérique) résout en UUID puis renvoie l'objet detail complet
- Réseau : 1 GET sur `/find/email/...` puis 1 GET sur `/{uuid}`, pas de 404

---

### Scénario 0e — MASS UPDATE (validation E-08, sans UI)

**Préconditions** : E-08 appliqué. Pas d'UI dans le frontend (les masses ne sont pas branchées) — test direct via curl.

```bash
# UUID-based (recommandé)
curl -X PUT "https://estair.../v1/admin/accounts" \
  -H "Authorization: Bearer ${JWT}" \
  -H "Content-Type: application/json" \
  -d '{
    "ids": ["uuid-1","uuid-2"],
    "data": { "status": "Inactif" }
  }'

# Hybride { id, uuid } (rétro-compat)
curl -X PUT "https://estair.../v1/admin/accounts" \
  -H "Authorization: Bearer ${JWT}" \
  -H "Content-Type: application/json" \
  -d '{
    "ids": [{"id":1,"uuid":"uuid-1"}, {"id":2,"uuid":"uuid-2"}],
    "data": { "status": "Inactif" }
  }'
```

**Attendu** :
- HTTP 200 si tous OK, ou 207 Multi-Status si certains ids échouent
- Réponse JSON contient `records` (mis à jour) et `errors` (échecs détaillés)
- En DB : les deux comptes ont `status = 'Inactif'` et `updated_at` récent
- Pas de PHP notice "Undefined variable $uuid" dans les logs Laravel

---

### Scénario 0f — MASS DELETE (validation E-09, sans UI)

**Préconditions** : E-09 appliqué.

```bash
curl -X POST "https://estair.../v1/admin/accounts/delete" \
  -H "Authorization: Bearer ${JWT}" \
  -H "Content-Type: application/json" \
  -d '{
    "ids": ["uuid-1","uuid-2"]
  }'

# Test ids numériques (rétro-compat)
curl -X POST "https://estair.../v1/admin/accounts/delete" \
  -H "Authorization: Bearer ${JWT}" \
  -H "Content-Type: application/json" \
  -d '{
    "ids": [1, 2]
  }'
```

**Attendu** :
- HTTP 200 si tous OK, 207 si partiel
- En DB : les ids ciblés ont `is_deleted = 1`
- Aucune erreur "table name cannot be null" dans les logs

---

### Scénario 1 — Rôle `agent` voit son module métier
1. Connexion avec utilisateur Keycloak rôle `agent`
2. Menu de gauche affiche les modules autorisés (depuis CHAPS menus)
3. Cliquer sur "Etats de ventes"
4. Liste s'affiche avec les colonnes définies par CHAPS (`list=1`)
5. Picklists colorées (statut `issue` en bleu, `void` en gris…)
6. Bouton "Nouveau" visible si CTA `create_list` configurée pour `agent`

### Scénario 2 — Workflow PNR
1. Ouvrir un PNR avec status `'En attente'`
2. Panneau detail affiche les boutons : Modifier, Lier au compte, Calculer le tarif, Confirmer le tarif
3. Aucun bouton "Émettre" (réservé au statut `'A émettre'`)
4. Cliquer sur Lier au compte → SpcOperationDialog s'ouvre avec opération préselectionnée

### Scénario 3 — Restriction par rôle
1. Configurer dans CHAPS Admin : `delete_detail` pour `accounts` réservé à `admin`
2. Connexion en tant que `support`
3. Detail panel d'un compte : pas de bouton Supprimer
4. Connexion en tant que `admin`
5. Detail panel du même compte : bouton Supprimer présent

### Scénario 4 — Idempotence dispatch
1. Modifier `displayname` de `accounts.email` → "Adresse électronique" en CHAPS Admin
2. Lancer dispatch manuel (`POST /chaps/admin/chaps/metadata/dispatch/data`)
3. Vérifier en CHAPS DB : `displayname` = "Adresse électronique" (préservé)
4. Ajouter une colonne `accounts.foo` en DB ESTAIR
5. Lancer dispatch
6. Nouveau metadata `accounts_foo` créé avec `list=1, sequencelist=999`

### Scénario 5 — Picklist coloré
1. Module `accounts`, statut `'En attente'`
2. Liste : chip orange (`color_back: #ffe5b4`, `color_front: #ff8c00`)
3. Detail : header chip avec mêmes couleurs
4. Édition : VSelect propose `Actif`, `Inactif`, `En attente` (libellés)
5. Sauvegarde : DB stocke `'En attente'` (literal)

---

# Annexe A — Glossaire

- **Pipeline B** : CTAs déclarées dans CHAPS table `ctas` × `ctas_enum`, retournées par view dans `*.cta`. Source unique de vérité pour les boutons UI.
- **CTAs métier** : actions hors CRUD canonique (void, refund, issue, link, etc.). Désormais déclarées dans `ctas_enum` au même titre que les autres.
- **Pipeline C (legacy)** : `availableActions` retourné par ESTAIR detail. Devient un FILTRE au-dessus du Pipeline B (présence dans availableActions = éligibilité métier de cette ligne précise).
- **Convention picklist** :
  - `picklists.value` = valeur stockée dans la table de données ESTAIR
  - `picklists.label` (futur) = libellé affiché ; sinon `value`
  - `picklists.name` = ID composite jamais affiché
- **CHAPS Admin** : `pages/admin/*` du frontend, qui pilote les tables `modules`, `ctas`, `metadatas`, `picklists`, `menus`.

# Annexe B — Liste des fichiers touchés

**Frontend** :
- `composables/useMetadata.ts` (F-01, F-02, F-06, D-02 client)
- `composables/useEstair.ts` (E-07 — alignement findBy + résolution id→uuid)
- `composables/useDynamicColumns.ts` (F-08)
- `composables/usePermissions.ts` (F-13 — suppression complète)
- `components/app/FieldRenderer.vue` (F-07, F-15)
- `components/app/FieldEditor.vue` (F-14)
- `components/app/EstairInlineTable.vue` (F-10)
- `components/crud/DetailPanel.vue` (F-03, F-09)
- `pages/app/[domaine]/[module]/index.vue` (F-11)
- `stores/auth.ts` (F-12)
- `types/chaps.ts` (F-04)
- `utils/iconMap.ts` (F-05 — création)

**ESTAIR backend** :
- `app/Http/Controllers/api/v1/CrudController.php` (E-01, E-02, **E-05, E-06, E-08, E-09**)
- `app/Http/Controllers/api/v1/backoffice/MetadataController.php` (E-03, E-04)
- `app/Librairies/BoService.php` (**E-06 — ajout `deleteByUuid()`**)
- `app/Services/ModulePermissionService.php` (T-01)
- `app/Console/Kernel.php` (E-04 — scheduler)

**CHAPS backend** :
- `app/Http/Controllers/MetadataController.php` (C-01, C-02, C-03, C-04)
- `app/Services/MetadataDispatchService.php` (E-04 réception, à créer ou adapter)

**SQL migrations CHAPS** :
- `2026_05_09_align_roles_to_canonical.php` (D-01)
- `2026_05_09_extend_ctas_enum_for_workflow_actions.php` (C-05)
- `2026_05_09_add_label_to_picklists.php` (D-02, P3)

---

**FIN DU DOCUMENT**
