# API `/transfert/{fiche}` — intégration BHP

## Le problème

BHP (système externe) a besoin d'échanger des fiches référentiel (chauffeur, client, chantier, formule, véhicule, origine, fournisseur...) avec CentralManager Web, sans qu'on ait à écrire un endpoint dédié par fiche. `api_transfert_controller` (`app/Http/Controllers/api_transfert_controller.php`) fournit un endpoint générique `GET`/`POST`/`DELETE` `/transfert/{fiche}` qui délègue à la logique métier existante de chaque contrôleur (`enregistrer()`, `supprimer()`), déjà utilisée par les formulaires web.

---

## Routes

```
routes/api.php, groupe auth:sanctum, prefix /transfert :

GET    /transfert/{fiche}   -> api_transfert_controller::demander_fiche
POST   /transfert/{fiche}   -> api_transfert_controller::enregistrer_fiche
DELETE /transfert/{fiche}   -> api_transfert_controller::supprimer_fiche
```

`{fiche}` est mis en minuscule et doit être une clé de `$modeles`/`$controleurs` dans `api_transfert_controller` (ex: `chauffeur`, `origine`, `fournisseur`...), sinon réponse `404` (`Fiche inconnue.`).

---

## Format du payload

Le corps peut être un objet unique ou une liste d'objets — les deux sont normalisés vers une liste en interne, et une réponse est renvoyée par fiche, dans le même ordre :

```json
[
  {
    "id_tracabilite_envoi": 20,
    "id_tracabilite_envoi_bhp": 50,
    "centrale_code": "01",
    "user_bhp": "Brody",
    "tracabilite_envoi_date_heure_bhp": "2026-09-03 11:33:00",
    "origine_code": "RANVILLE",
    "origine_libelle": "RANVILLE",
    "fournisseur": { "fournisseur_code": "503446" }
  }
]
```

- `<fiche>_code` (ou `chantier_code_complet` pour chantier) identifie la fiche pour savoir s'il s'agit d'une création ou d'une mise à jour.
- `id_tracabilite_envoi_bhp` et `user_bhp` sont renvoyés tels quels dans chaque réponse, et utilisés pour construire l'auteur de la traçabilité (voir plus bas).
- Une fiche liée s'envoie en objet imbriqué (`"fournisseur": {"fournisseur_code": "..."}`), résolue automatiquement (voir §Résolution des fiches liées). `null` explicite = fiche liée retirée.

---

## Convention par contrôleur de fiche

Chaque contrôleur métier (`origine_controller`, `fournisseur_controller`, `chauffeur_controller`...) expose, en plus de ses méthodes web habituelles (`enregistrer()`, `supprimer($id)`), deux méthodes dédiées à l'API :

- **`enregistrer_api(Request $request)`** : résout le PK propre de la fiche via son `*_code` (`$fiche = X::where('X_code', ...)->first()`), merge `<fiche>_id` dans la requête, puis délègue à `$this->enregistrer($request)`. Les FK vers des fiches liées (`fournisseur_id`, `transporteur_id`...) n'ont **pas** à être résolues ici : `api_transfert_controller::resoudre_liaisons()` le fait déjà en amont.
- **`supprimer_api(Request $request)`** : cherche la fiche par `code` (paramètre `code` du payload/query), `404` si introuvable, sinon délègue à `supprimer($id, true, $tracabilite_envoi_bhp, $token_name.' - '.$user_bhp, $tracabilite_envoi_date_heure_bhp)`.
- **`supprimer($id, $api=false, $tracabilite_envoi_bhp=null, $user='', $date_heure_bhp='')`** : mêmes contrôles métier que la version web (droit profil, fiche utilisée ailleurs), mais chaque réponse porte en plus `errors` (souvent la chaîne littérale `'null'`, par convention existante) et `id_tracabilite_envoi_bhp`. Sur un blocage métier, `success` passe à `true` quand `$api` est vrai — pour éviter qu'un retry BHP boucle indéfiniment sur une fiche qu'on refuse de toucher — sauf sur « non trouvée », qui reste toujours `false`. `$date_heure_bhp` est transmis tel quel à `ecrire_traca_std(..., $date_heure_bhp)` (voir §Auteur et horodatage de la traçabilité).
- **`enregistrer()`** : la réponse d'échec de validation porte `message`/`id_tracabilite_envoi_bhp` en plus de `errors`, la réponse de succès porte `errors`/`id_tracabilite_envoi_bhp`. Un blocage métier propre à la fiche (droit insuffisant selon le profil, ex: `classe_chlorure`/`classe_consistance`/`classe_exposition`) suit la même règle : `message` + `id_tracabilite_envoi_bhp` en plus d'`errors`.

### Auteur et horodatage de la traçabilité

`ecrire_traca_std($feuille, $enreg, $action, $user='', $date_heure='')` (`app/fonctions/tracabilite_fonction.php`) est appelée avec :

```php
$this->tracabilite->ecrire_traca_std(
    "Chauffeur", $chauffeur->chauffeur_code, $action_tracabilite,
    $request->user()?->currentAccessToken()?->name
        ? $request->user()->currentAccessToken()->name.' - '.$request->input('user_bhp')
        : $this->user->name,   // repli formulaire web (pas de token Sanctum)
    $request->has('tracabilite_envoi_date_heure_bhp') ? $request->input('tracabilite_envoi_date_heure_bhp') : ''
);
```

- **Auteur (`$user`)** : token Sanctum + `user_bhp` du payload si appel API, sinon `$this->user->name` (utilisateur web connecté). ⚠️ Ne pas copier le repli `''` d'`origine_controller`/`fournisseur_controller` (historique) pour une fiche activement éditée en UI : ça blanchirait l'auteur de la traça lors d'un enregistrement via le formulaire web.
- **Horodatage (`$date_heure`)** : si `tracabilite_envoi_date_heure_bhp` est fourni dans le payload, `ecrire_traca_std` l'utilise comme `tracabilite_date_heure` (heure **réelle de l'événement** côté BHP) et remplit `tracabilite_date_integration_reelle`/`tracabilite_heure_integration_reelle` avec `now()` (heure **réelle de l'intégration** ici) — ça distingue les deux moments. Sans valeur, `tracabilite_date_heure` = `now()` et les colonnes "réelle" restent `null`.
- Pour une suppression, le même `$date_heure_bhp` (venant de `supprimer_api()`) est transmis en 5ᵉ argument de `supprimer()` puis de `ecrire_traca_std()`.

---

## Résolution automatique des fiches liées (`resoudre_liaisons`)

`enregistrer_fiche()`/`supprimer_fiche()` appellent `resoudre_liaisons($modele, $ligne)` sur chaque ligne du payload, **avant** de déléguer au contrôleur métier. Elle :

1. Inspecte les méthodes `lier_*` du modèle (relations `belongsTo` uniquement, pas les `belongsToMany`).
2. Pour `lier_fournisseur`, cherche la clé `fournisseur` dans le payload (préfixe `lier_` retiré).
3. Si absente : ne touche à rien. Si `null` : merge `fournisseur_id = 0`. Si objet `{"fournisseur_code": "..."}` : trouve la colonne `*_code` de la table liée, cherche l'enregistrement correspondant, merge `fournisseur_id` (nom réel de la FK, via `getForeignKeyName()`) — `0` si le code n'est pas trouvé.

Ça marche pour n'importe quel modèle ayant une méthode `lier_xxx` — pas de code à écrire par fiche pour ce cas.

---

## Rattachement aux centrales (`resoudre_centrales`)

Pour les fiches qui ont une relation `lier_<fiche>_centrale` (`belongsToMany` vers `centrale`) — listées dans `api_transfert_controller::$modeles_centrale` (**client, chauffeur, vehicule, chantier, formule, service, produit**) — `enregistrer_fiche()`/`supprimer_fiche()` calculent aussi un tableau `centrales`, consommé par le `sync()` du contrôleur métier (`$model->lier_<fiche>_centrale()->sync($request->input('centrales'))`). Nécessaire car BHP n'envoie qu'un `centrale_code` unique (la centrale émettrice), pas un tableau.

Pilotage via `t_parametre.parametre_integration_<fiche>` (colonne booléenne par fiche, existe pour `client`, `chantier`, `chauffeur`, `vehicule`, `service`, `produit` — pas pour `formule`) :

- **`= 1`** → fiche **commune** à toutes les centrales actives (`centrale_active = 1`) : `centrales` = tous leurs ids.
- **`= 0` / absent** → fiche **locale** à la centrale émettrice : `centrales` = id de la centrale résolue via `centrale_code`, **plus** les centrales déjà liées à la fiche existante (recherchée par son `*_code`), pour qu'une mise à jour ne fasse pas perdre un rattachement précédent (`sync()` remplace tout le pivot, contrairement à un simple insert ciblé).

La clé réelle utilisée (`id_centrale` ou `centrale_code` selon la relation — `chantier` utilise `centrale_code` comme clé pivot, pas `id_centrale`) est lue dynamiquement via `getRelatedKeyName()`, jamais codée en dur.

⚠️ **Colonnes ambiguës (corrigé le 2026-09-24)** : les tables pivot `t_client_centrale` et `t_chantier_centrale` ont elles aussi une colonne `centrale_code`. Le `pluck()` des centrales déjà liées est donc qualifié (`$relation->getRelated()->qualifyColumn($cle_liee)` → `t_centrale.centrale_code`), et les scopes globaux `order`/`active` de `app/Models/centrale.php` qualifient aussi leurs colonnes (`qualifyColumn()`). Avant ça, en mode local, toute mise à jour d'un client ou d'un chantier déjà rattaché à une centrale plantait en 500 (« centrale_code ambigu »). À garder en tête pour toute nouvelle requête qui joint `t_centrale` à un pivot.

⚠️ **Casse du `<fiche>_code`** : les clés de tableau PHP sont sensibles à la casse (contrairement aux noms de colonnes MySQL, insensibles à la casse). La plupart des fiches suivent la convention `<fiche>_code` tout en minuscule (`chauffeur_code`, `origine_code`...), mais certaines colonnes DB ont une casse différente (`vehicule` → `Vehicule_Code`, `chantier` → en réalité `chantier_code_complet`, pas `chantier_code`). `api_transfert_controller::$colonnes_code_speciales` liste ces exceptions ; `resoudre_centrales()` s'en sert pour savoir sous quelle clé chercher le code dans le payload. À vérifier/compléter à chaque nouvelle fiche dont le payload BHP utilise la casse réelle de la colonne plutôt que la convention minuscule.

⚠️ **Bug corrigé le 2026-09-28 — `colonne_code()` insensible à `$colonnes_code_speciales`** : ce helper privé (utilisé par `resoudre_liaisons()` pour résoudre un FK depuis une fiche liée imbriquée en POST, et par `getBelongsToRelations()` pour construire le `with()` restreint en GET) devine le nom de la colonne code via `preg_replace('/^t_/', '', $table) . '_code'` puis compare avec `in_array(..., true)`/`str_ends_with()` — tous deux **sensibles à la casse**, et sans jamais consulter `$colonnes_code_speciales`. Pour `t_vehicule.Vehicule_Code`, ça ne matchait jamais : `colonne_code()` renvoyait `null`, donc `lier_vehicule` (présent sur `commande`, `bl`, `planning`, `planning_prev`, `incident`) n'était **jamais résolu en POST** (FK silencieusement ignorée), et en GET tombait dans le fallback "pas de colonne code" qui charge **toutes** les colonnes de `t_vehicule` sans restriction (au lieu de juste `id_vehicule,Vehicule_Code`). Corrigé en rendant la comparaison insensible à la casse (`strcasecmp`, et suffixe `_code` comparé en minuscule) — retourne la colonne avec sa casse réelle telle qu'en base. Un scan de tous les modèles câblés dans l'API a confirmé que `vehicule` est le seul cas concerné actuellement. Vérifié via tinker (`colonne_code('t_vehicule', ...)` → `Vehicule_Code` ; `getBelongsToRelations()` → `lier_vehicule:id_vehicule,Vehicule_Code` ; `resoudre_liaisons()` sur `commande` avec `{"vehicule": {"Vehicule_Code": "01"}}` → `vehicule_id` correctement résolu).

Cette logique reproduit le comportement de l'ancien endpoint legacy `public/trans/action_trans_chauffeur.php` (delete+insert ciblé sur une seule centrale si `=0`, boucle sur toutes les centrales actives + notification `t_tracabilite_envoi` des autres centrales si `=1`).

---

## État d'avancement par fiche

| Fiche | `enregistrer_api`/`supprimer_api` | Dans `$modeles_centrale` |
|---|---|---|
| origine | ✅ (historique) | non concerné (pas de `lier_centrale`) |
| fournisseur | ✅ (historique) | non concerné |
| chauffeur | ✅ (ajouté 2026-09-05) | ✅ |
| classe_chlorure | ✅ (ajouté 2026-09-05) | non concerné (pas de `lier_*`) |
| classe_consistance | ✅ (ajouté 2026-09-05) | non concerné (pas de `lier_*`) |
| classe_exposition | ✅ (ajouté 2026-09-05) | non concerné (pas de `lier_*`) |
| transporteur | ✅ (ajouté 2026-09-05) | non concerné (pas de `lier_*`) |
| vehicule | ✅ (ajouté 2026-09-15) | ✅ — payload utilise `Vehicule_Code` (casse exacte colonne DB), voir `$colonnes_code_speciales` |
| type_beton | ✅ (ajouté 2026-09-15) | non concerné (pas de `lier_*`) |
| designation_beton | ✅ (ajouté 2026-09-15) | non concerné (pas de `lier_*`) |
| specificite | ✅ (ajouté 2026-09-15) | non concerné (pas de `lier_*`) |
| classe_resistance | ✅ (ajouté 2026-09-15) | non concerné (pas de `lier_*`) |
| famille_beton_echantillon | ✅ (ajouté 2026-09-15, demandé comme "famille_beton") | non concerné (pas de `lier_*`). Corrigé 2026-09-24 : la traça de `enregistrer()` écrivait `"Suppression"` en dur au lieu de `$action_tracabilite` |
| nature_addition | ✅ (ajouté 2026-09-15) | non concerné (pas de `lier_*`) |
| mode_reg | ✅ (ajouté 2026-09-15) | non concerné (pas de `lier_*`). Corrigé 2026-09-24 : validation `mode_reg_libelle` `max:50` → `max:25` (colonne `varchar(25)`, MySQL non strict tronquait en silence) |
| pays | ✅ (ajouté 2026-09-15) | non concerné (pas de `lier_*`) |
| unite | ✅ (ajouté 2026-09-15) | non concerné (pas de `lier_*`) |
| position_compte | ✅ (ajouté 2026-09-15) | non concerné (pas de `lier_*`). Corrigé 2026-09-24 : colonne `position_compte_alarme` jamais créée (ligne commentée dans la migration V900) → erreur 500 dès que le champ était envoyé ; migration `2026_09_24_1216_V904005_BAM_t_position_compte_position_compte_alarme` |
| tva | ✅ (ajouté 2026-09-15) | non concerné (pas de `lier_*`) |
| zone | ✅ (ajouté 2026-09-15) | non concerné — a des `lier_*` (tva, famille_article, groupe_tarif, typologie_article) donc `resoudre_liaisons` s'applique, mais pas de `lier_centrale`. Corrigé 2026-09-24 dans `supprimer()` : `t_commune.zone_id` n'existait pas (500 sur toute suppression ; migration `2026_09_24_1339_V904005_BAM_t_commune_zone_id`), et les contrôles devis/commande/BL testaient `$chantier` au lieu de leur ligne, sans filtrer le type d'article (`*_type_article = 4` = zone) |
| client | ✅ (ajouté 2026-09-15) | ✅ — voir §Rattachement aux centrales, colonnes ambiguës (corrigé 2026-09-24) |
| chantier | ✅ (ajouté 2026-09-15) | ✅ — clé pivot = `centrale_code` (pas `id_centrale`), géré par `getRelatedKeyName()`. Champ PK propre = `id_chantier` (pas `chantier_id`), code = `chantier_code_complet` (`$colonnes_code_speciales`). Plusieurs FK (`formule_id`, `centrale_id`, `motif_echec_reussite_id`, `concurrent_id`, `modele_impression_id`, `ventilation_id`, `modele_qr_code_id`) n'ont pas de relation `lier_*` sur le modèle donc ne sont **pas** résolues par `resoudre_liaisons()` |
| formule | ✅ (ajouté 2026-09-15/16) | ✅ — pas de `parametre_integration_formule` (pas de migration prévue), donc toujours la branche "locale" (centrale émettrice ajoutée sans retirer les autres) |
| civilite | ✅ (ajouté 2026-09-15) | non concerné (pas de `lier_*`) |
| type_vehicule | ✅ (ajouté 2026-09-17) | non concerné (pas de `lier_*`). Corrigé 2026-09-24 : `supprimer()` n'avait aucun contrôle (droit `profil_fichier_facturation`, type utilisé par un véhicule) ; code validé `max:2` (colonne `varchar(2)`) au lieu de `max:12` |
| service | ✅ (ajouté 2026-09-22) | ✅ — `parametre_integration_service` existe en base |
| produit | ✅ (ajouté 2026-09-22) | ✅ — `parametre_integration_produit` existe en base. Corrigé au passage 2 relations cassées du modèle : `lier_origine` pointait vers `fournisseur`/`famille_produit_id` (copier-coller) → `origine`/`origine_id` ; `lier_unite` référençait une colonne `unite_id` inexistante → remplacé par `lier_unite_vente`/`lier_unite_achat` (colonnes réelles), avec mise à jour des 2 `with()` dans `produit_controller.php` qui l'utilisaient |
| commande | ✅ (ajouté 2026-09-28) | non concerné (pas de `lier_centrale`, `centrale_id` est un simple belongsTo) |
| groupe_tarif | ✅ (ajouté 2026-09-29) | non concerné (pas de `lier_*`, fiche liée *depuis* ajout/produit/service/zone/formule via `groupe_tarif_id`, jamais l'inverse). Corrigé au passage : validation `groupe_tarif_libelle` `max:50` → `max:25` (colonne `varchar(25)`, MySQL non strict tronquait en silence — même bug déjà vu et corrigé sur `mode_reg`) |
| famille_article | ✅ (ajouté 2026-09-29) | non concerné (pas de `lier_*`, fiche liée *depuis* ajout/produit/service/zone/formule via `famille_article_id`/`famille_produit_id`/`famille_service_id`, jamais l'inverse). Corrigé au passage : même bug de validation `famille_article_libelle` `max:50` → `max:25` (colonne `varchar(25)`) |
| reglage_trappe | ✅ (ajouté 2026-09-29) | non concerné (pas de `lier_*`, référencée depuis `vehicule.reglage_trappe_id` mais sans relation `lier_*` sur `vehicule`, donc pas de blocage de suppression prévu — contrairement à groupe_tarif/famille_article, `enregistrer()`/`supprimer()` d'origine n'avaient aucun contrôle de droit `profil_fichier_*` ; conservé tel quel, pas ajouté de contrôle qui n'existait pas) |

**commande** a un traitement propre, comme formule :
- GET (`demander_fiche`) : chaque commande reçoit un tableau imbriqué **`ligne`** (toutes ses `ligne_commande`, retrouvées par `commande_code`), chaque ligne avec un champ **`article_code`** résolu selon `ligne_commande_type_article` : 0→`formule`, 1→`service`, 2→`ajout` (catalogue `t_ajout`), 3 et 5→`produit`, 4→`zone` (résolution groupée par type, pas requête par requête — reste correct même en `code=ALL`). Type `9` (ligne "commentaire", découvert en vérifiant les vraies données) et tout type non couvert : `article_code` reste `null`.
- POST (`enregistrer_api`) : symétrique — chaque ligne du payload envoie `article_code` (même nom que le GET) + `ligne_commande_type_article`, résolu en sens inverse (code → id) par `integrer_lignes_commande_api()`, qui **remplace tout** (delete + réinsertion) pour ce `commande_code`, comme `composition` côté formule. La ligne de type 0 (formule) fait autorité sur `formule_id` de la commande (cascade, comme `ligne_commande_enregistrer()` côté web).
- ⚠️ **Ordre important** : les lignes sont remplacées **avant** d'appeler `enregistrer()`, car celle-ci valide (portage de `commande_valider_tr.php`) que chaque ligne *déjà en base* référence un article rattaché à la centrale soumise — avec d'anciennes lignes d'une autre centrale, cette validation échouerait à tort si elle passait après. Le tout est dans une **transaction** (`DB::beginTransaction`/`commit`/`rollBack` selon `success` de la réponse `enregistrer()`) : si l'enregistrement de la commande échoue pour une autre raison, le remplacement des lignes est annulé avec. Testé de bout en bout (payload réel, échec puis succès, rollback vérifié).
- **Non répliqué** dans `integrer_lignes_commande_api()` (le payload BHP est supposé déjà cohérent) : plafond de quantité du type 3 (`produit_maximum`), unicité (une seule ligne type 0/5, une seule ligne type 4), recalcul de prix des BL existants (`repercuterPrixLigneCommandeSurBl`). Le prix (`ligne_commande_prix_force_prix`) est toujours fourni par l'appelant, jamais recalculé côté serveur (idem web).
- `commande_controller` a le même bug de coercion booléenne que les autres fiches (`$request->has($champ) ? 1 : 0` sur une bonne dizaine de champs `commande_confirmer_*`/`commande_complement`/etc.) — pas corrigé, cf. section formule plus haut.

**formule** a un traitement propre en plus du pattern standard :
- `enregistrer_api()` résout `centrale_id` depuis `centrale_code` (pas de relation `lier_centrale` sur ce modèle) et le merge dans la requête — nécessaire pour le bloc `formule_detail` de `enregistrer()`, qui lit `$request->input('centrale_id')` directement.
- `formule_detail` est envoyé imbriqué (`"formule_detail": {...}`) mais lu à plat par `enregistrer()` : `enregistrer_api()` aplatit l'objet dans la requête (en excluant `id_formule_detail`/`formule_id`/`centrale_id`/`created_at`/`updated_at` pour ne pas écraser ceux déjà résolus), et fixe `formule_detail->formule_variant_id` à la variante active (`formule_variant::getActive()`), jamais renseigné auparavant.
- `composition` (tableau de lignes) est intégré par `integrer_composition_api()` : remplace tout (delete + réinsertion) pour le couple formule/centrale/variante active. Chaque ligne envoie `composition_produit_code`/`composition_produit_libelle` tels quels (mêmes noms que le GET) ; `produit_id` est résolu via `produit_code` seulement pour `composition_type` 0-4 (produit catalogue), pas pour 5 (ajout manuel) ni 6 (rien).
- `demander_fiche()` (GET) renvoie aussi `composition` et `formule_detail` imbriqués pour la centrale de `centrale_code` (variante active), symétrique du POST. `centrale_code` est **obligatoire** pour `fiche=formule` en GET (sinon réponse `{success:false, message:"Centrale obligatoire."}`).
- **Booléens (corrigé le 2026-09-24)** : `enregistrer()` forçait les 8 booléens (`formule_active`, `formule_protegee`, `formule_definir_production`, `formule_tarif_imprimable`, `formule_lavage`, `formule_normalisee`, `formule_tps_std`, `formule_volume_std`) via `$request->has($champ) ? 1 : 0` : un `0` explicite envoyé par BHP était enregistré à `1`. Le bloc `merge()` est supprimé ; `resources/views/formule/form.blade.php` a maintenant un `<input type="hidden" value="0">` devant chaque checkbox (`value="1"`), comme les autres fiches, et la validation `boolean` + `fill(validated())` suffit. Un booléen absent du payload n'est plus forcé à 0 (valeur conservée en modification, défaut de colonne en création). Les autres fiches câblées (chauffeur, client, vehicule, chantier, zone...) n'avaient déjà plus ce bug.
- **`formule_lavage`** : checkbox et validation existaient mais la colonne n'avait jamais été créée (ligne commentée dans la migration V900) — la valeur était ignorée silencieusement. Migration `2026_09_24_1443_V904005_BAM_t_formule_champs_checkbox_boolean` : crée la colonne et passe les 7 autres checkbox (`int(1)`/`bit(1)`) en `boolean`.
- **Validation de `formule_detail` avant `$formule->save()` (corrigé le 2026-09-24)** : avant, un `formule_detail` invalide (ex: `formule_na` manquant) renvoyait `success:false` alors que la formule était déjà écrite, sans détail, traça ni centrales.

`classe_consistance` a par ailleurs un ancien endpoint API distinct et toujours actif (`routes/api.php`, groupe `v1/classe_consistance`, `POST /enregistrer`). `classe_consistance_controller::enregistrer()` avait une détection `$request->is('api/*')` pour résoudre son propre id via son code (redondante avec `enregistrer_api()` depuis que la fiche est câblée sur `/transfert/`) — retirée le 2026-09-05, c'était un test. `afficher_formulaire()` garde en revanche sa détection `$request->is('api/*') || $request->expectsJson()` pour servir les routes GET `/api/v1/classe_consistance/{id}` et `/all` en JSON, non touchée.

---

## Tests (`tests/Feature/ApiTransfert/`)

Une classe PHPUnit par fiche, dans l'ordre du tableau ci-dessus (`OrigineTransfertTest`, `FournisseurTransfertTest`, ... `ProduitTransfertTest`) + `GeneriqueTransfertTest` (401 sans token, 404 fiche inconnue, nom de fiche insensible à la casse, objet unique normalisé en liste, `tracabilite_envoi_envoye` en GET avec token `bhp`, DELETE avec `code` en query string).

Toutes héritent d'`ApiTransfertTestCase`, qui :
- crée un vrai token Sanctum `BHP_TEST` (ability `bhp`) et met les droits de `$droitsProfil` à 5 sur le profil de `User::first()` (en oubliant les guards pour qu'un changement de droit en cours de test soit vu) ;
- fournit 13 scénarios standard hérités : GET (401, liste, par code, code inconnu), POST (création, mise à jour sans doublon, liste de 2 fiches, sans code, sans champ obligatoire, ligne invalide dans la liste), DELETE (ok, code inconnu, sans droit → `success:true` mais fiche conservée) ; la traça est vérifiée (auteur `BHP_TEST - Brody`, `tracabilite_date_heure` = `tracabilite_envoi_date_heure_bhp`, date d'intégration réelle renseignée).

Chaque classe fille déclare sa config (`$fiche`, `$table`, `$colonneCode`, `$droitsProfil`, `$champObligatoire`, `payloadValide()`, `champModifiable()`) et ajoute ses scénarios propres (longueurs max, fiches liées imbriquées, centrales selon `parametre_integration_<fiche>`, suppressions bloquées « utilisée dans... », booléens à 0...). Un test hérité sans objet pour une fiche est surchargé en `markTestSkipped()`.

Les tests tournent sur la base de dev (`.env`) avec `DatabaseTransactions` : tout est annulé en fin de test. Éviter de les lancer pendant qu'on édite les mêmes fiches dans l'appli (risque de deadlock MySQL, faux échec).

```
php artisan test tests/Feature/ApiTransfert                       # toutes les fiches
php artisan test --filter=OrigineTransfertTest                    # une fiche
php artisan test tests/Feature/ApiTransfert/VehiculeTransfertTest.php   # vehicule (--filter matcherait aussi TypeVehicule)
```

Au 2026-09-24 : 696 tests — 684 passés, 0 échec, 12 skippés (faute de données en base ou sans objet pour la fiche). Pour une nouvelle fiche câblée, créer `<Fiche>TransfertTest` sur le même modèle.

À compléter au fil de l'eau, un contrôleur à la fois — voir `Architecture.md` (§6, bloc « Intégration BHP ») pour le suivi haut niveau.
