---
suivi: 1481
date: 2026-09-08
sujet: Re-liaison staging SIWI ↔ marchés ERP déjà créés
chantier: reprise_siwi
type: fix
statut: poussé
hash: cf73b7c2
fichiers:
  - app/Console/Commands/SiwiRelierExistantCommand.php
  - app/Services/SiwiRelierExistantService.php
  - tests/Unit/SiwiRelierExistant1481Test.php
  - tests/Unit/SiwiRelierExistant1481SourceTest.php
  - docs/suivi/SUIVI_1481_relier_staging_siwi_existant.md
---

# SUIVI #1481 — relier le staging SIWI aux marchés ERP existants

## PROMPT ENVOYÉ

Commande artisan `siwi:relier-existant {--apply}` (dry-run par défaut) pour
reconstituer les liaisons staging perdues après le DELETE #1479 et le
réimport CSV. Écriture uniquement dans `siwi_*`. Pas de `siwi:import`,
pas d’objet ERP créé/modifié, pas de SharePoint, pas de migration, pas de .vue.

## BASE GIT

```
git log --oneline -1 origin/main
2a6f0f52 chore(suivi): hash fiche #1480
```

(Attendu prompt : `2a6f0f52` — conforme.)

## FICHES LUES

- **#1480** — script destructeur supprimé, Deny `database/`, refus source
  vide. LEÇON : ne pas relancer `siwi:import` ici. Acquis préservés.
- **#1479** — DELETE sans WHERE a vidé le staging ; TRAVAIL perdu avec les
  lignes. LEÇON : un réimport recrée des lignes SOURCE neuves.
- **#1370** — garde CLI `siwi:import`, seuil `--force-replace` 50. La
  nouvelle commande reprend le même garde `PHP_SAPI !== 'cli'`.
- **#1324 / #1326** — upsert SOURCE only, TRAVAIL survit au réimport
  *si la ligne existe encore*. Après DELETE, il n’y a plus de ligne.

Aucune fiche ne change l’approche : re-liaison, pas reprise.

---

## MESURES ÉTAPE 1 (code, pas MySQL)

### 1. `SiwiRepriseActionService` — écritures TRAVAIL

**Créer marché** (`creerMarche`, L395–400) :
`etat_cloture = cree` · `marche_hectarion_id` · `decision_user_id` ·
`decision_at` · `etat_depot_docs = en_attente`.
Alimente aussi `marches.reference_siwi` à la création (L286) + ligne
`marche_reprise_lignes.libelle = 'Reprise SIWI {ref}'` (L380–393).

**Rattacher marché** (`rattacherMarcheExistant`, L550–554) :
`etat_cloture = rattache` · `marche_hectarion_id` · `decision_user_id` ·
`decision_at`. N’écrit **pas** `etat_depot_docs`. Pose
`marches.reference_siwi` si vide (L539–547).

**Créer programme** (L138–145) :
`programme_hectarion_id` · `etat_liaison_programme = cree` · `entite_id`
· `programme_lie_user_id/at` · `sharepoint_sync_status = en_attente`.
**N’écrit pas** `etat_programme` (reste NULL).

**Rattacher programme** (L190–197) :
`etat_liaison_programme = rattache` · `entite_id` depuis
`$programme->entite_id`. Idem : `etat_programme` inchangé.

**Hors périmètre / écarté** (`cloturerMarche`, L944–948) :
`etat_cloture` + `motif_cloture` + décision user. Aucun objet ERP.

**Non repris** : `SiwiImportService::marquerProgrammeNonRepris`
(`etat_programme = non_repris`) — pas `SiwiRepriseActionService`.

### 2. `SiwiImportPageService` — avancement / verrou

- Ligne close = `etat_cloture IS NOT NULL` (L676). Segments : cree /
  rattache / hors_perimetre / ecarte (L678–682).
- Programmes contrôlés = `controle_at IS NOT NULL` (L689).
- Non repris = `etat_programme = non_repris` (L690–692).
- Groupes Créé/Rattaché/Écarté → `traitees` ; Hors périmètre →
  `hors_perimetre` (`assignMarcheGroupe` L1295–1299).
- Verrou marchés : `estLie()` = `etat_liaison_programme` ∈ {cree,rattache}
  **ET** `programme_hectarion_id` non null (`SiwiOperation` L51–55).
  Message « créer ou rattacher un programme ERP… » si pas `estLie()`
  (L1434–1442). `etat_programme !== non_repris` pour débloquer (L248).

La commande doit donc remplir : `marche_hectarion_id` + `etat_cloture`
(cree|rattache) sur les marchés, et `programme_hectarion_id` +
`etat_liaison_programme` (cree|rattache) sur les opérations. C’est
suffisant pour l’écran. `*_retenu` non lu pour le verrou.

### 3. Pivot opération ↔ programme

`programme_hectarion_id` **ne suffit pas**. `estLie()` exige aussi
`etat_liaison_programme` ∈ {cree, rattache}. `entite_id` est écrit par
l’action manuelle (affichage / cohérence) mais n’entre pas dans `estLie()`.

### 4. `marches.reference_siwi`

Unique nullable (#1323). Posé à la création (L286) et au rattachement
si vide. Trace ERP des 209 marchés (mesure Robin). Clé de rapprochement
vérifiée : `siwi_marches.ref_siwi` = `marches.reference_siwi`.

### 5. Colonnes `*_retenu`

Utilisées à la **création** ERP (intitulé, montants, RG) et en fallback
d’affichage (`poste_retenu_code ?? poste_propose_code`). Après réimport,
`poste_propose_*` (SOURCE) est de nouveau présent. **Non nécessaires**
au verrou / avancement. Non reconstituées (évite de mélanger un
instantané ERP avec un arbitrage perdu).

### 6. `SiwiImportPolicy` / `decision_user_id`

Page : `GS_GROUPE` via `m365GroupNames()` brut. CLI : pas d’utilisateur.
`decision_user_id` et `decision_at` restent **NULL**. Log
`siwi.relier_existant` avec `system: true` (pattern #1453).
`programme_lie_user_id` NULL ; `programme_lie_at` = now() (marquage
système d’horloge, pas un user inventé).

### Énumérations (lues dans les modèles)

| Colonne | Valeurs dans le code |
|---------|----------------------|
| `etat_cloture` | `cree` · `rattache` · `hors_perimetre` · `ecarte` |
| `etat_depot_docs` | `non_parti` · `en_attente` · `partiel` · `en_echec` · `ok` |
| `etat_programme` | `non_repris` seulement (lié = NULL) |
| `etat_liaison_programme` | `cree` · `rattache` |
| SharePoint (non touché) | `non_tente` · `en_attente` · `ok` · `en_echec` |

---

## C — L’import écrase-t-il les décisions ?

**Non. Le DELETE a détruit les lignes ; l’upsert a recréé des lignes SOURCE
neuves avec TRAVAIL NULL.**

Preuve :

- `SiwiImportService` L21 : « Idempotent : SOURCE rafraîchie, TRAVAIL préservée. »
- `MARCHES_SOURCE_COLUMNS` (L62–76) : colonnes SOURCE uniquement. Pas
  `etat_cloture`, `marche_hectarion_id`, `note_arbitrage`, `*_retenu`,
  `decision_*`.
- `upsertMarche` L1141–1143 : « SOURCE only — ne pas toucher TRAVAIL ».
  Sur **création** (ligne absente, cas post-DELETE) : SOURCE +
  `etat_depot_docs = non_parti` (L1135–1137).
- `upsertOperation` L1091–1107 : `$source` sans
  `programme_hectarion_id` / `etat_liaison_programme` / `etat_programme` /
  notes / contrôle.

`SiwiImportService` **n’est pas modifié** (pas un défaut d’écrasement).
Le #1480 a déjà fermé la porte du DELETE.

Protection durable proposée (non implémentée, justifiée) : conserver
l’upsert SOURCE-only actuel. Un second filet possible plus tard :
refuser `siwi:import --apply` si des colonnes TRAVAIL sont non nulles
*et* le volume SOURCE changerait — hors scope, l’upsert ne les touche
déjà pas tant que les lignes existent.

---

## Tableaux de mapping

### 1. Marchés (`siwi_marches`)

| Colonne staging | Source | Valeur écrite | Rec. | Si non |
|-----------------|--------|---------------|------|--------|
| `marche_hectarion_id` | `marches.id` via `reference_siwi` | id ERP | oui | — |
| `etat_cloture` | ligne `marche_reprise_lignes` « Reprise SIWI {ref} » → `cree`, sinon `rattache` | `cree` ou `rattache` | oui | — |
| `etat_depot_docs` | action créer | `en_attente` si `cree` et actuellement `non_parti` | oui | rattache : non touché |
| `fournisseur_hectarion_id` | `marches.fournisseur_id` | id si null | oui | — |
| `decision_user_id` | — | inchangé (NULL) | non | pas d’user CLI |
| `decision_at` | — | inchangé (NULL) | non | ne pas inventer une date de décision |
| `*_retenu` (poste, intitulé, montants, RG) | — | inchangé | non nécessaire | écran lit SOURCE `poste_propose_*` |
| `note_arbitrage` / `motif_cloture` | — | inchangé | non | texte libre perdu |
| `etat_cloture` hors_perimetre / ecarte | — | non écrit | non | aucune trace ERP |

### 2. Opérations (`siwi_operations`)

| Colonne staging | Source | Valeur écrite | Rec. | Si non |
|-----------------|--------|---------------|------|--------|
| `programme_hectarion_id` | `marches.programme_id` des marchés liés (un seul) | id | oui | multi-programmes → rien, listé |
| `etat_liaison_programme` | marchés du programme sans `reference_siwi` → `rattache`, sinon `cree` | `cree` ou `rattache` | oui (heuristique) | les deux déverrouillent `estLie()` |
| `entite_id` | `programmes.entite_id` | id si null | oui | — |
| `etat_programme` | action créer = NULL | inchangé (NULL) | oui comme NULL | `non_repris` perdu |
| `programme_lie_user_id` | — | NULL | non | pas d’user |
| `programme_lie_at` | horloge CLI | now() si null | marquage système | — |
| `controle_user_id` / `controle_at` | — | inchangé | non | aucune autre table |
| `note_programme` / `motif_programme` / `libelle_retenu` | — | inchangé | non | texte libre |
| `sharepoint_sync_*` | — | inchangé | non touché | Graph bloqué en CLI |

### 3. Énumérations — valeur retenue / effet écran

| Colonne | Valeurs code | Retenue par la commande | Effet écran |
|---------|--------------|-------------------------|-------------|
| `etat_cloture` | cree, rattache, hors_perimetre, ecarte | cree si ligne reprise SIWI, sinon rattache | close la ligne ; badge Créé / Rattaché ; groupe traitées |
| `etat_depot_docs` | non_parti, en_attente, partiel, en_echec, ok | en_attente si cree | file dépôt docs ; pas le verrou |
| `etat_programme` | non_repris (sinon NULL) | non écrite | NULL ≠ non repris → pas le message « non repris » |
| `etat_liaison_programme` | cree, rattache | cree ou rattache | `estLie()` vrai → déverrouille les marchés |

### 4. Cas non reconstituables

| Décision perdue | Trace ERP | Action Robin |
|-----------------|-----------|--------------|
| Hors périmètre / écarté / ne pas reprendre | non | re-trancher à la main sur l’écran |
| Notes (`note_arbitrage`, `note_programme`) | non | resaisie si besoin |
| Motifs (`motif_cloture`, `motif_programme`) | non | resaisie si besoin |
| Programme contrôlé (`controle_*`) | non (`controle_at` uniquement staging) | re-cocher « Programme contrôlé » |
| `etat_programme = non_repris` | non | re-marquer « ne pas reprendre » |
| `*_retenu` distincts du marché ERP | non (marché a intitulé/montants) | optionnel, écran utilise SOURCE |
| `decision_user_id` | `marche_historique.user_id` type creation existe pour les **créés**, pas exploité (CLI ≠ user) | aucune |
| Docs dépôt / SharePoint sync | hors CLI | ne pas relancer une sync Graph depuis cette commande |

`marche_historique` (type `creation`, L359–369) et
`marche_reprise_lignes` (« Reprise SIWI {ref} ») **existent** et
servent à distinguer cree / rattache. Pas de table d’audit SIWI, pas
de `facture_historique` utile ici.

---

## Commande

Signature LITTÉRALE (fichier) :

`siwi:relier-existant {--apply : Écrit dans les tables siwi_* (dry-run par défaut)}`

Namespace `siwi:` (comme `siwi:import`). Garde CLI. Dry-run par défaut.
Idempotence : ne remplit que les NULL (sauf `etat_depot_docs` :
`non_parti` → `en_attente` pour un `cree`). Second `--apply` → 0
écriture. Ambiguïté multi-programmes : aucune écriture opération,
liste dans le rapport.

Sortie dry-run attendue (structure ; les chiffres viennent de la base
au moment de l’exécution, **non inventés ici**) :

```
=== siwi:relier-existant (dry-run) ===

Marchés
  liés (à écrire / écrits)     : …
  déjà liés                    : …
  ERP absents du staging       : …
  décision hors périmètre/écarté préservée : …
  conflits id déjà renseigné   : …
  Références ERP absentes du staging :
    - …

Opérations
  liées (à écrire / écrites)   : …
  déjà liées                   : …
  ambiguës (multi-programmes)  : …
  sans marché lié              : …
  non repris (préservées)      : …
  Détail des opérations ambiguës :
    - OPCODE → programmes id id (libelle | libelle)

Modifications réellement écrites : 0
Aucune écriture (dry-run). Relancer avec --apply pour écrire dans siwi_*.
```

SQL (relecture statique, non exécuté) : `Marche::whereNotNull('reference_siwi')`
puis `SiwiMarche::where('ref_siwi', $ref)` (égalité trimée, unique des
deux côtés). Colonnes qualifiées via Eloquent (pas de JOIN ambigu).
Pas de `NULL = NULL`. Pas de DDL.

---

## LEÇON

Après un DELETE staging, `siwi:import` est innocent : il recrée des
lignes SOURCE. Les décisions ne reviennent pas avec les CSV. La trace
exploitable est `marches.reference_siwi` + `marche_reprise_lignes`.
`estLie()` exige **deux** colonnes (`programme_hectarion_id` et
`etat_liaison_programme`), pas seulement l’id. Ne jamais inventer un
`user_id` en CLI.

## DÉPLOIEMENT-TEST

```
cd /home/hectare-envol/envol.hectare.fr
git pull origin main
php artisan optimize:clear
php artisan view:clear
find app/ config/ database/ -name "*.php" -exec touch {} +
php artisan siwi:relier-existant
```

Lire le dry-run. Si le rapport est cohérent (liés ≈ 209, absents
staging listés, ambiguës à arbitrer) :

```
php artisan siwi:relier-existant --apply
php artisan siwi:relier-existant --apply
```

Le second `--apply` doit afficher 0 modification.

JAMAIS `npm run build` / `composer install` / `optimize` / `config:cache`.
NE PAS relancer `siwi:import`.

Requêtes de contrôle (à exécuter par Robin, chiffres non inventés) :

```
SELECT COUNT(*) AS marches_lies
FROM siwi_marches
WHERE marche_hectarion_id IS NOT NULL AND etat_cloture IN ('cree','rattache');

SELECT COUNT(*) AS operations_liees
FROM siwi_operations
WHERE programme_hectarion_id IS NOT NULL
  AND etat_liaison_programme IN ('cree','rattache');

SELECT m.reference_siwi
FROM marches m
LEFT JOIN siwi_marches s ON s.ref_siwi = m.reference_siwi
WHERE m.reference_siwi IS NOT NULL AND m.reference_siwi <> '' AND s.id IS NULL
  AND m.deleted_at IS NULL;

SELECT COUNT(*) AS lignes_closes FROM siwi_marches WHERE etat_cloture IS NOT NULL;
SELECT COUNT(*) AS programmes_controles FROM siwi_operations WHERE controle_at IS NOT NULL;
```

Écran reprise : SAS RACINE (et les autres opérations liées) ne doivent
plus afficher « Marchés verrouillés — créer ou rattacher un programme
ERP » si la liaison opération a été écrite. Avancement : N lignes
closes / 1405 (N = marchés liés + hors périmètre/écarté re-tranchés).
Programmes contrôlés restent à 0 jusqu’à re-coche manuelle.
