# Intégration MASSIA — export BL / import Formule

## Origine

Module ajouté le **2026-09-07** pour échanger des données avec **MASSIA**,
le logiciel qualité/formulation béton de l'éditeur **Arcade**, utilisé en
centrale. L'échange est **bidirectionnel** :

- **Sortant** : le système envoie un bon de livraison (BL) à Massia
  (format `BatchProtocolDto`).
- **Entrant** : Massia envoie une formule béton au système
  (format `InterfaceFormuleDto`).

Les règles de correspondance de codes (classes d'exposition, résistance,
chlorure, fournisseurs...) proviennent d'un document fourni par Arcade :
*"Interface Massia-Alfi_Formules"* / *"Interface Massia-Alfi_Produits"*
(onglets *3-mapping* et *4-Règles de gestion*).

- **Contrôleurs** : `app/Http/Controllers/Massia/`
- **Services** : `app/Services/Massia/`
- **Commande artisan** : `app/Console/Commands/massia_exporter_bl_command.php`

## État actuel : module branché

| Élément | État |
|---|---|
| Routes | `POST /api/massia/bl/{bl}/exporter` et `POST /api/massia/formule` dans [routes/api.php](../routes/api.php), middleware `auth:sanctum`. **Hors du groupe `v1`** : l'interface Massia a son propre préfixe, indépendant du versionnage des API internes |
| Config | Clé `massia` (`base_url`, `api_key`) dans [config/services.php](../config/services.php), alimentée par `MASSIA_BASE_URL` / `MASSIA_API_KEY` dans `.env` |
| Colonne `bl_exporte` | Présente en base sur `t_bl` (vérifié via `Schema::hasColumn`), mais **sans migration trackée** — à créer si la base doit être reconstruite à partir des migrations |
| Planification | `massia:exporter-bl` n'est pas enregistrée dans `Console/Kernel.php` (déclenchement manuel uniquement) |
| Tests | `tests/Feature/Massia/` (export + import), lancés par `php artisan test --filter=Massia` |

Les deux flux ont été validés contre l'environnement de test de Massia
(`test.adler.massia.eu`). Voir aussi [Architecture.md §6ter](../Architecture.md).

---

## 1. Flux sortant — export d'un BL vers Massia

### Schéma d'appel

```
POST /api/massia/bl/{bl}/exporter
        │
        ▼
massia_bl_export_controller::exporter(bl $bl, BlMassiaExportService $service)
        │
        ▼
BlMassiaExportService::exporter(bl $bl)
        │
        ├─► BlMassiaMapper::mapper($bl)        → construit le payload JSON
        ├─► MassiaApiClient::post('', $payload) → POST HTTP vers Massia
        └─► si succès : $bl->bl_exporte = 1 ; $bl->save()
```

### `massia_bl_export_controller` ([app/Http/Controllers/Massia/massia_bl_export_controller.php](../app/Http/Controllers/Massia/massia_bl_export_controller.php))

Contrôleur volontairement très fin (route-model-binding sur `bl`, injection
du service par le conteneur) :

```php
public function exporter(bl $bl, BlMassiaExportService $service)
{
    $resultat = $service->exporter($bl);

    return response()->json($resultat, $resultat['success'] ? 200 : 422);
}
```

Aucune vérification d'habilitation/rôle dans le contrôleur lui-même : elle
devra être portée par le middleware de la route (`auth:sanctum` +
probablement une `ability` dédiée, à décider).

### `BlMassiaExportService::exporter()` ([app/Services/Massia/BlMassiaExportService.php](../app/Services/Massia/BlMassiaExportService.php))

1. Construit le payload via `BlMassiaMapper::mapper($bl)`.
2. `try { $reponse = $client->post('', [$payload]); }` — **le payload est envoyé
   enveloppé dans un tableau** (`BatchProtocolDto[]`), même pour un envoi
   unitaire. Confirmé le 2026-09-18 par test réel contre `test.adler.massia.eu` :
   un objet seul à la racine fait échouer la requête côté Massia avec un message
   générique intercepté (`"Une erreur est survenue lors du traitement"`), voire
   une exception .NET brute non interceptée (`ArgumentOutOfRangeException` sur un
   payload vide) — signe que leur endpoint indexe la requête comme une collection.
   Si une exception réseau
   survient : `report()` + `Log::error()`, retourne
   `['success' => false, 'message' => ..., 'payload' => $payload]`.
3. Si `$reponse->failed()` (HTTP 4xx/5xx) : `Log::warning()`, retourne
   `['success' => false, 'message' => 'Massia a refusé le BL (HTTP xxx)', 'payload' => ..., 'reponse' => ...]`.
4. Si succès : `$bl->bl_exporte = 1; $bl->save();`, retourne
   `['success' => true, 'message' => ..., 'payload' => ..., 'reponse' => $reponse->json()]`.

Le service **n'écrit jamais aucune exception non interceptée** vers
l'appelant : adapté à un déclenchement batch (commande artisan) comme à un
appel HTTP unitaire.

### `BlMassiaMapper::mapper()` ([app/Services/Massia/BlMassiaMapper.php](../app/Services/Massia/BlMassiaMapper.php))

Construit le JSON `BatchProtocolDto` à partir d'un `bl` Eloquent. Charge
d'abord les relations nécessaires :

```php
$bl->loadMissing([
    'lier_client', 'lier_chantier', 'lier_centrale', 'lier_formule',
    'lier_pesee.lier_produit_dose.lier_fournisseur',
    'lier_pesee.lier_produit.lier_fournisseur',
]);
```

Structure du payload produit :

```
{
  "CodePlant": <centrale.centrale_code>,
  "MDRefMassia": null,
  "DeliveryNote": {
      "DeliveryNoteNumber": <suffixe numérique de bl_code>,
      "DeliveryDate": <bl_date_livraison + bl_heure_livraison, ISO "Y-m-d\TH:i:s">,
      "IsPicked": <true si bl_code_bl_retour_beton renseigné, sinon null>,
      "OriginDNNumber": <suffixe numérique de bl_code_bl_retour_beton>,
      "DeliveredQuantity": <bl_qte_livree ?? bl_qte_livraison>,
      "MixerNumber": <bl_numero_malaxeur>,
      "BatchingOrderNumber": null,
      "MixDesignCost": null,
      "Batches": [ ... une entrée par gâchée (pesee_no_gachee) ... ],
      "TypeAchatVente": null,
      "TypeEntreeSortie": null
  },
  "WorkSite":  { "Label", "RefExternal", "Adresse", "Ville", "CodePostal", "Tel", "DateHDebut", "DateHFin" },
  "Consumer":  { "Label": client_nom, "RefExternal": client_code },
  "Recipe":    { ... voir formule ci-dessous ... }
}
```

**Gâchées et matières premières** (`mapperGachees` / `mapperMatierePremiere`) :
les pesées du BL (`lier_pesee`) sont groupées par `pesee_no_gachee`. Pour
chaque matière première d'une gâchée, la quantité cible théorique
(`TargetRecipeValue`) est retrouvée via la table `composition`
(`composition_quantite`), mise en correspondance par `produit_id`.

**Formule / recette** (`mapperFormule`) : si le BL a une formule liée
(`lier_formule`), reprend ses classes (désignation, consistance, résistance,
exposition, chlorure), le détail centrale (`formule_detail` filtré par
`centrale_id` du BL, pour `E_sur_C` / `Dosage_CKA` / `Granularite`), et la
nature d'addition. Sinon, retombe sur `bl_formule_code_produit` avec tout le
reste à `null`.

**Champs sans équivalent actuel (envoyés à `null`)**, documentés en tête de
fichier : `MDRefMassia`, `BatchingOrderNumber`, `MixDesignCost`,
`BatchQuantity`/`WaterCorrection`/`RealMixingTime`/`MixingPower` (par gâchée),
`Hygrometry`/`IsHygrometryMeasured`, `PetitD`/`GrandD`, `IsCorrecteur`,
`Certification`/`Attestation`/`Nature_Ciment`/`Cube_Cylindre`/`Adjuvants`/
`Remarque` (côté formule).

**Fonctions de conversion utilitaires** :
- `versEntier()` : `bl_code` (ex. `"D1BP00033154"`) et
  `bl_code_bl_retour_beton` mélangent un préfixe centrale/type à un numéro
  séquentiel → extrait la suite de chiffres finale par regex `/(\d+)$/`.
- `versDecimal()` : les colonnes `decimal` MySQL remontent en chaîne via
  PDO (ex. `"0.000"`) → recast en `float` pour un JSON conforme.
- `versDateHeure()` : combine date + heure en `"Y-m-d\TH:i:s"`.
- `mapperFamille()` : fait correspondre le libellé de famille produit du
  système (`produit_type_libelle`) à l'énumération Massia attendue
  (`ADD`/`ADJ`/`AJOUT`/`CIM`/`EAU`/`GRAN`) par recherche de sous-chaîne
  (best-effort, retombe sur le libellé brut si aucune correspondance).

### `MassiaApiClient` ([app/Services/Massia/MassiaApiClient.php](../app/Services/Massia/MassiaApiClient.php))

Client HTTP minimaliste basé sur `Illuminate\Support\Facades\Http` :

```php
Http::withHeaders(['X-API-KEY' => $this->apiKey])->acceptJson()->timeout(30)
    ->post($this->baseUrl.'/'.$chemin, $payload);
```

`$baseUrl` = `config('services.massia.base_url')`,
`$apiKey` = `config('services.massia.api_key')` — **ni l'un ni l'autre
n'existent encore dans `config/services.php`**.

### Commande artisan `massia:exporter-bl` ([app/Console/Commands/massia_exporter_bl_command.php](../app/Console/Commands/massia_exporter_bl_command.php))

```bash
php artisan massia:exporter-bl              # tous les BL non annulés, non exportés (bl_exporte = 0)
php artisan massia:exporter-bl D1BP00033154 # un BL précis, même déjà exporté
```

Réutilise `BlMassiaExportService::exporter()` par BL, comptabilise
succès/échecs, logue un résumé (`Log::info`), code retour `SUCCESS`/`FAILURE`
selon présence d'échecs. **Non planifiée** dans `Console/Kernel.php` — à
lancer manuellement pour l'instant.

---

## 2. Flux entrant — import d'une formule depuis Massia

### Schéma d'appel

```
POST /api/massia/formule
        │
        ▼
massia_formule_import_controller::importer(Request $request, FormuleMassiaImportService $service)
        │
        ├─► Validator: CodeBeton requis, Centrale.Code optionnel
        ▼
FormuleMassiaImportService::importer(array $payload)   [DB::transaction]
        │
        ├─► firstOrNew + upsert sur `formule`
        ├─► firstOrNew + upsert sur `formule_detail` (par centrale)
        └─► remplacerComposition() : delete + recreate `composition` (par centrale)
```

### `massia_formule_import_controller` ([app/Http/Controllers/Massia/massia_formule_import_controller.php](../app/Http/Controllers/Massia/massia_formule_import_controller.php))

```php
$champsValide = Validator::make($request->all(), [
    'CodeBeton' => ['required', 'string'],
    'Centrale.Code' => ['nullable', 'string'],
]);

if ($champsValide->fails()) {
    return response()->json(['success' => false, 'message' => 'Payload invalide', 'errors' => $champsValide->errors()], 422);
}

$resultat = $service->importer($request->all());
return response()->json($resultat, $resultat['success'] ? 200 : 422);
```

Seuls `CodeBeton` et `Centrale.Code` sont réellement validés en entrée ; le
reste du payload est passé tel quel au service (pas de `Rules` par champ).

### `FormuleMassiaImportService::importer()` ([app/Services/Massia/FormuleMassiaImportService.php](../app/Services/Massia/FormuleMassiaImportService.php))

Toute la logique tourne dans une **transaction DB unique**
(`DB::transaction(...)`) :

1. **Résolution de la centrale** : si `Centrale.Code` est fourni, recherche
   `centrale::withoutGlobalScope('active')->where('centrale_code', ...)`
   (⚠️ ignore volontairement le scope "actif" — une centrale inactive peut
   quand même recevoir sa formule). Si absente/non trouvée, la formule est
   quand même créée/mise à jour, mais **sans composition** (log
   `Log::warning`), et le résultat retourné porte
   `'centrale_resolue' => false`.

2. **Extraction du code formule** (`extraireCodeFormule`, règle **RG01**) :
   `CodeBeton` Massia = code centrale Alfi + code formule Alfi concatenés
   sans séparateur (ex. `"D02C12533B"` = centrale `"D02"` + formule
   `"C12533B"`). Le préfixe centrale n'est retiré **que si** la centrale a
   été résolue **et** que `CodeBeton` commence effectivement par son code —
   sinon `CodeBeton` est utilisé tel quel comme code formule.

3. **Upsert de la formule** (`formule::firstOrNew(['formule_code' => $codeFormule])`) :

   | Champ `formule` | Source payload | Transformation |
   |---|---|---|
   | `formule_nom` | `NomBeton` | — |
   | `formule_libelle_commercial` | `AppellationCommercialeBeton` | — |
   | `formule_code_facturation` | (calculé) | = `$codeFormule` |
   | `formule_active` | `EstActive` | cast bool→int, défaut `1` si absent et formule nouvelle |
   | `designation_beton_id` | `DesignationBeton.CodeElement` | résolution directe par code |
   | `classe_resistance_id` | `ClasseResistance.CodeElement` | via `transformerCodeResistance()` |
   | `classe_exposition_id` | `ClasseExposition.CodeElement` | via `transformerCodeExposition()` |
   | `classe_consistance_id` | `ClasseConsistance.CodeElement` | résolution directe par code |
   | `classe_chlorure_id` | `ClasseChlorure.CodeElement` | via `transformerCodeChlorure()` |
   | `type_beton_id` | `TypeBeton` | via `resolutionTypeBeton()` (code OU libellé) |
   | `specificite_id` | `GrandD` | préfixé `"D"` (ex. `10` → `"D10"`) |
   | `formule_type` | — | forcé à `0` (BPE) **uniquement à la création** |
   | `formule_definir_production` | — | forcé à `1` à chaque import : une formule reçue de Massia est considérée comme destinée à la production (conditionne aussi le type de traçabilité d'envoi, voir point 5) |

   Chaque `resolutionElement()` fait un simple `where($champCode, $code)->first()`
   sur le modèle cible ; si rien ne correspond, la valeur existante de la
   formule est conservée (pas d'écrasement par `null`).

4. **Transformations de codes** (règles issues du document Arcade, avec
   leurs limites documentées en tête de fichier) :
   - `transformerCodeExposition()` : retire le préfixe `"X"` (`"XC1"` → `"C1"`).
   - `transformerCodeResistance()` : ne garde que les chiffres, puis prend la
     **première moitié** (arrondie au supérieur) de la chaîne de chiffres
     (`"C2530"` → chiffres `"2530"` → moitié `"25"` → `"25"`).
   - `transformerCodeChlorure()` : ne garde que les chiffres, retire les zéros
     de tête (`"Cl040"` → `"040"` → `"40"`). Règle déduite d'un seul exemple
     documenté ; les cas non standards (ex. 1,0 %) peuvent ne pas se résoudre
     et sont alors laissés à `null` plutôt que mal assignés.

5. **Si la centrale est résolue** :
   - Résout la **variante active** (`formule_variant::getActive()`, fallback
     `0` si aucune variante en base) — même pattern que
     [`formule_controller`](../app/Http/Controllers/formule_controller.php)
     et [`composition_calcul_trait`](../app/Traits/composition_calcul_trait.php).
     `t_composition` et `t_formule_detail` portent tous les deux une colonne
     `formule_variant_id` (ajoutée en 2026-07/2026-02) : le reste de l'appli
     lit/écrit toujours ces tables filtrées par la variante active, donc
     l'import Massia doit faire pareil pour rester visible/cohérent avec le
     reste de l'application.
   - Rattache la centrale à la formule dans le pivot **`t_formule_centrale`**
     (`$formule->lier_formule_centrale()->syncWithoutDetaching([$centrale->id_centrale])`)
     si ce n'est pas déjà le cas. C'est ce pivot que `formule_controller`
     utilise pour déterminer les formules disponibles à une centrale donnée
     (ex. recherche de formule dans une commande, filtre par centrale) — sans
     cette liaison, la formule importée existe bien mais n'apparaît nulle
     part comme utilisable à cette centrale. `syncWithoutDetaching()` (plutôt
     que `sync()`) pour ne jamais détacher les autres centrales déjà liées à
     cette formule.
   - **Trace l'envoi vers production** via
     [`tracabilite_envoi_fonction`](../app/fonctions/tracabilite_envoi_fonction.php)
     (`ecrire_tracabilite_envoi('T_Formule', $formule->formule_code, $action, $formule->lier_formule_centrale()->get())`),
     avec `$action` = `'Création'` ou `'Modification'` selon que la formule
     existait déjà — même pattern que
     [`formule_controller`](../app/Http/Controllers/formule_controller.php).
     C'est ce qui déclenche la prise en compte de la formule par les automates
     de centrale. ⚠️ Cette fonction écrit aussi une entrée `"Suppression"` pour
     **toutes les autres centrales actives non liées** à la formule
     (comportement hérité, identique au contrôleur) : un import pour une seule
     centrale génère donc plusieurs lignes de traçabilité. L'appel n'a lieu que
     si une centrale a été résolue — avec une liste vide, cette même logique
     marquerait toutes les centrales en `"Suppression"`.
   - Upsert `formule_detail` (clé `formule_id` + `centrale_id` +
     `formule_variant_id`) : `formule_granularite` (←
     `Granulometrie.NomElement` ou `CodeElement`), `formule_air` (←
     `VolumeAir`, arrondi entier), `formule_gwr` (← `PoidsCO2`, **défaut `0`**
     — colonne `NOT NULL` en base ; sans ce défaut, importer une nouvelle
     formule sans `PoidsCO2` dans le payload faisait échouer l'insertion).
   - `remplacerComposition()` : **supprime toute la composition existante**
     pour le triplet `(formule_id, centrale_id, formule_variant_id)` puis la
     recrée entièrement (avec ce même `formule_variant_id`) à partir de
     `MatieresPremieres` (voir ci-dessous). ⚠️ Remplacement total, pas de
     fusion — un import partiel écraserait les lignes non renvoyées, mais
     uniquement celles de la variante active (les autres variantes ne sont
     plus touchées).

   Sinon (centrale non résolue) : un avertissement est ajouté (voir point 6),
   `Log::warning('massia_api: centrale non résolue pour la formule '.$codeBeton, ...)`,
   composition **non touchée**.

6. **Avertissements** : chaque code de classification (désignation, résistance,
   exposition, consistance, chlorure, type béton, spécificité) non résolu en
   base — ainsi qu'une centrale non résolue — ajoute une entrée dans un
   tableau `$avertissements`, sans jamais faire échouer l'import (le champ
   correspondant reste simplement non renseigné). Si ce tableau n'est pas
   vide, il est journalisé (`Log::warning`) et repris dans le message retourné.

7. **Retour** — `formule_id` **n'est volontairement pas exposé** dans la
   réponse (identifiant interne Alfi, sans intérêt pour Massia) :
   ```php
   [
       'success' => true,
       'message' => "Formule {$codeFormule} importée" . ($avertissements ? ' (avec avertissements : ...)' : ''),
       'centrale_resolue' => $centraleResolue,
       'avertissements' => $avertissements, // tableau de chaînes, vide si tout s'est résolu
   ]
   ```

### `remplacerComposition()` — détail par matière première

Pour chaque entrée de `MatieresPremieres` (ignorée si `Code` absent) :

1. `produit::firstOrNew(['produit_code' => $code])`, mis à jour :
   `produit_libelle` (← `Libelle`), `produit_code_facturation` (= `$code`),
   `produit_densite` (← `Densite`), `produit_absorbtion` (← `Absorption`),
   `produit_alcalin`/`produit_chlorure`/`produit_coef_addition`
   (← `TeneurEnAlkalin`/`TeneurEnChlorure`/`KAddition`, **défaut `0`** — ces
   colonnes sont `NOT NULL` en base), `produit_type_ciment` (←
   `TypeCiment.CodeElement`), `produit_type_addition` (←
   `TypeAddition.CodeElement`).
2. `produit_type_libelle` : traduit `Famille` (`CIM`/`GRAN`/`EAU`/`ADJ`/
   `ADD`/`AJOUT`) vers un libellé français via la constante
   `LIBELLES_FAMILLE` — uniquement si `Famille` correspond à une clé connue.
3. `fournisseur_id` via `resolutionFournisseur()` (voir ci-dessous).
4. `composition::create()` : `centrale_id`, `formule_id`,
   `formule_variant_id` (variante active résolue en amont), `produit_id`,
   `composition_quantite` (← `Quantite`, défaut `0`),
   `composition_produit_code`/`composition_produit_libelle` (dénormalisés
   depuis le payload, pour historique/affichage).

### `resolutionFournisseur()` — règle des codes fournisseur

Les codes fournisseur Alfi se terminent par `"--"` (ex. `"F101819--"` pour
le producteur Massia `"F101819"`), convention observée en base de données
réelles. La recherche essaie d'abord `code + "--"`, puis le `code` brut ; si
aucun des deux n'existe, un **nouveau fournisseur est créé** avec le code
suffixé `"--"` et le libellé = nom du producteur (ou le code lui-même si le
nom est absent).

### Champs du DTO Massia sans équivalent actuel (ignorés, v1)

Documenté en tête de `FormuleMassiaImportService` :
`Certificats`, `ClasseTauxSubstitution`, `InfoTechnique1-3`, `DosageLiant`,
`CaractereComplementaire`, `ClasseCO2`, `InfoSecurite`, `Leq`,
`ModeCalculPourcentageAdjuvant`, `FormuleEauTotale`, `ClientsLies`, et côté
matières premières : `TeneurEnEau`, `TeneurEnExtraitSec`,
`EstAdditionCorrectrice`, `NatureGranulatRecycle`, `TypeGranulatRecycle`,
`ClasseResistanceCiment`, `FamilleAdjuvant`,
`CaracteristiquesComplementairesCiment`, `PetitD`/`GrandD` (au niveau
matière première — à ne pas confondre avec `GrandD` au niveau formule, qui
lui est utilisé pour `specificite_id`).

---

## Pour rendre le module testable — checklist

1. **Routes** — ajouter dans [routes/api.php](../routes/api.php), typiquement :
   ```php
   Route::middleware(['auth:sanctum'])->prefix('/massia')->name('massia.')->group(function () {
       Route::post('/bl/{bl}/exporter', [massia_bl_export_controller::class, 'exporter'])->name('bl.exporter');
       Route::post('/formule', [massia_formule_import_controller::class, 'importer'])->name('formule.importer');
   });
   ```
   Décider du middleware d'auth pour le sens entrant (Massia → nous) :
   probablement une clé API dédiée plutôt que Sanctum utilisateur, symétrique
   à `X-API-KEY` utilisé côté sortant.

2. **Config** — ajouter dans `config/services.php` :
   ```php
   'massia' => [
       'base_url' => env('MASSIA_BASE_URL'),
       'api_key'  => env('MASSIA_API_KEY'),
   ],
   ```
   et les variables correspondantes dans `.env`.

3. **Migration** — ajouter la colonne `bl_exporte` (`tinyint`/`boolean`,
   défaut `0`) sur `t_bl`.

4. **(Optionnel)** planifier `massia:exporter-bl` dans `Console/Kernel.php`
   si l'export doit devenir automatique plutôt que déclenché à la demande.

## Pistes de test une fois branché

- **Export** : choisir un BL existant non annulé avec au moins une pesée et
  une formule liée, appeler `POST /massia/bl/{bl}/exporter` (ou
  `php artisan massia:exporter-bl {bl_code}`), vérifier le payload généré
  (`BlMassiaMapper::mapper()` peut être testé unitairement sans appel HTTP
  réel) et le passage de `bl_exporte` à `1`.
- **Import** : construire un payload `InterfaceFormuleDto` minimal
  (`CodeBeton`, `Centrale.Code`, quelques `MatieresPremieres`), poster sur
  `POST /massia/formule`, vérifier la création/mise à jour de `formule`,
  `formule_detail` et le remplacement de `composition` ; tester aussi le cas
  `Centrale.Code` absent/inconnu (formule créée, composition non touchée).
- Vérifier en particulier les cas limites documentés : centrale inactive
  (`withoutGlobalScope('active')`), code résistance/chlorure non standard,
  fournisseur déjà existant sans suffixe `--`.
