---
suivi: 1459
date: 2026-09-04
sujet: Diagnostic — validateur_id doublement encodé (JSON_SET + JSON_QUOTE)
chantier: depot_factures
type: diagnostic
statut: poussé
hash: ef9bbfc4
fichiers:
  - docs/suivi/SUIVI_1459_diag_validateur_double_encode.md
---

# SUIVI #1459 — Diagnostic validateur double-encodé (PHASE 1)

## PROMPT ENVOYÉ

PHASE 1 diagnostic. Ne pas coder, ne pas corriger de données. Identifier le
point d’écriture de `ocr_raw.saisie.validateur_id` doublement encodé (14 dépôts
ignorés par `taches:backfill-completer-saisie`). Instruire l’hypothèse d’un
défaut mutualisé avec l’édition inline des avis d’imposition (#1435). Mesures
MySQL interdites (127.0.0.1:3306 refused) : seules les mesures du prompt sont
acquises ; ailleurs, fournir la requête.

## FICHES LUES

- **#1453** — `depot-factures:backfill-validateur` `{--apply}` ; JSON_SET d’une
  seule clé `$.saisie.validateur_id`. Tests sqlite **sans** `JSON_QUOTE`.
  LEÇON lue : ne pas réécrire tout `ocr_raw`. C’est **le** point d’écriture
  fautif (branche MySQL).
- **#1458** — 32 examinés / 18 créées / 14 ignorés « valideur non résolu ».
  Backfill `taches:backfill-completer-saisie` idempotent via `idempotency_key`.
  Ne pas dupliquer les 18. Commande #1453 non modifiée (acquis).
- **#1457** — valideur = JSON `ocr_raw.saisie.validateur_id` (pas de colonne) ;
  format mixte azure_id / `users.id`.
- **#1450** — filtre « Mes factures à compléter » peuplé
  (`whereSaisieValidateurEstUser`) ; défaut `a_completer = true` ; enregistrement
  permissif + `assertRattachementBudgetCompletPourEnvoi` à ne pas défaire.
- **#1435** — avis d’imposition : `$validator->validated('valeur')` (Laravel 13
  `Validator::validated()` sans argument) écrivait `{champ, valeur}` entier.
  **Corrigé** dans le code actuel. Pas le même helper que le dépôt.

Base : `origin/main` `b24c013b` (`chore(suivi): hash fiche #1458`), vérifié par
`git log --oneline -1 origin/main` avant diagnostic. `origin/main..HEAD` vide.

MySQL local : non utilisé. Aucun chiffre inventé.

## CLAUSE D’ARRÊT

**Non déclenchée.** Le point d’écriture fautif est identifiable avec certitude :
branche **MySQL** de `BackfillDepotFactureValidateurCommand::appliquerJsonSetValidateurId`
(`JSON_SET` + `JSON_QUOTE`). Ce n’est pas un import externe. Corriger
n’exige **pas** de changer le cast Eloquent `ocr_raw => array`.

---

## A. LE POINT D’ÉCRITURE

### A1. Recensement des écritures de `ocr_raw.saisie.validateur_id`

Il n’existe **pas** de colonne `validateur_id` sur `depot_factures`
(migration `database/migrations/2026_05_07_143000_create_depot_factures_table.php`
L29 : `$table->json('ocr_raw')->nullable()`). Toutes les écritures passent par
le JSON `ocr_raw`.

Aucun seed / aucune migration ne pose `saisie.validateur_id`. Unique `JSON_SET`
applicatif du dépôt : la commande #1453.

| # | Fichier | Lignes | Forme écrite | Double-encodage possible |
|---|---------|--------|--------------|--------------------------|
| 1 | `app/Console/Commands/BackfillDepotFactureValidateurCommand.php` | L204–220 (MySQL) | `JSON_SET(..., '$.saisie.validateur_id', JSON_QUOTE(azure_id ou CAST(id AS CHAR)))` | **OUI — c’est la cause** |
| 2 | même fichier | L225–247 (sqlite) | `json_set(..., azure_id brut, sans json_quote)` | NON (tests verts, pas prod) |
| 3 | `DepotFactureController::applyDepotSaisieFromValidated` | L1638 + L1653–1664 + L1725 | `$saisie['validateur_id'] = $validated['validateur_id'] ?? null` puis `$depot->update(['ocr_raw' => $ocrRaw])` via tableau PHP | NON si la string POST est nue |
| 4 | `DepotFactureController::update` | L1761–1818 | délègue à (3) | NON (même chemin) |
| 5 | `DepotFactureController::ensureValidateurDeposantParDefaut` | L3832–3856, appelé L496 | `$saisie['validateur_id'] = $azureId` **ou** `(string) $user->id` puis `$depot->update(['ocr_raw' => $raw])` | NON |
| 6 | `DepotFactureController::mergeMasseSaisieDepuisUpload` | L3802–3826 | `(string) $masse['validateur_id']` | NON |
| 7 | `FactureValidateurNv1AssignmentService::assign` | L84–88 | `(string) $user->id` (entier ERP, pas UUID Graph) | NON |
| 8 | `ValidateurNv1ReconciliationService` | L180–183 | `(string) $resolvedId` (entier ERP) | NON |

Extraits sourcés.

**#1453 MySQL (fautif) :**

```
app/Console/Commands/BackfillDepotFactureValidateurCommand.php L208–216
SET df.ocr_raw = JSON_SET(
    IF(
        JSON_EXTRACT(df.ocr_raw, '$.saisie') IS NULL,
        JSON_SET(IFNULL(df.ocr_raw, JSON_OBJECT()), '$.saisie', JSON_OBJECT()),
        df.ocr_raw
    ),
    '$.saisie.validateur_id',
    JSON_QUOTE(IF(u.azure_id IS NOT NULL AND TRIM(u.azure_id) <> '', u.azure_id, CAST(u.id AS CHAR)))
)
```

**Eloquent saisie (sain, à conserver) :**

```
DepotFactureController.php L1638
'validateur_id' => $validated['validateur_id'] ?? null,

L1653–1654
$ocrRaw = (array) ($depot->ocr_raw ?? []);
$ocrRaw['saisie'] = $saisie;

L1725
$depot->update($updates);
```

**Défaut déposant #1450 (sain) :**

```
DepotFactureController.php L3848–3856
$saisie['validateur_id'] = $azureId !== '' ? $azureId : (string) $user->id;
...
$depot->update(['ocr_raw' => $raw]);
```

**Front dépôt — pas d’InlineEditableField :** grep `InlineEditableField` dans
`resources/js/Pages/DepotFactures` = 0. Le POST saisie envoie un champ scalaire :

```
DepotFactureSaisieForm.vue L6627–6628
validateur_id: data.validateur_id ?? null,
validateur_email: data.validateur_email ?? null,
```

### A2. Mécanisme précis du double encodage

MySQL 8.4, doc JSON_SET : un `val` SQL de type chaîne est stocké **comme
chaîne JSON** (un niveau de guillemets). Exemple officiel :

```
JSON_SET(@j, '$.c', '[true, false]')
→ {"c": "[true, false]"}
```

(`https://dev.mysql.com/doc/refman/8.4/en/json-modification-functions.html`)

`JSON_QUOTE(azure_id)` (doc 14.17.2) enveloppe déjà la valeur : pour
`c437ff40-…` elle produit la **chaîne SQL** `"c437ff40-…"` (guillemets
inclus). `JSON_SET` traite ensuite cette chaîne SQL comme contenu JSON →
document `{"saisie":{"validateur_id":"\"c437ff40-…\""}}`.

`JSON_UNQUOTE(JSON_EXTRACT(..., '$.saisie.validateur_id'))` renvoie alors
`"c437ff40-…"` **avec** les guillemets littéraux — exactement la mesure prod
du prompt (`brut="c437ff40-…"`).

`TRIM(BOTH CHAR(34) FROM …)` retire ce surplus → UUID nu → `users.azure_id`
(14/14 actifs, mesure du prompt).

Ce n’est **pas** : cast `array` + `json_encode` manuel (les chemins Eloquent
posent une string PHP nue). Ce n’est **pas** `->update()` d’un tableau dans
une colonne scalaire. C’est **uniquement** `JSON_SET` recevant une valeur
déjà quotée par `JSON_QUOTE`.

La branche **sqlite** de la même commande (L234–242) passe `u.azure_id` **sans**
`json_quote`. Le test
`tests/Unit/BackfillDepotFactureValidateurCommandDbTest.php` L67–68
`assertSame('azure-dep-1', $saisie['validateur_id'])` est donc vert en CI et
**aveugle** au défaut MySQL de production.

### A3. Cast Eloquent `ocr_raw`

```
app/Models/DepotFacture.php L933–953
'ocr_raw' => 'array',
```

Eloquent json-encode **une fois** le tableau PHP à l’écriture. Une string
`'c437ff40-…'` dans `$saisie['validateur_id']` devient `"c437ff40-…"` en JSON
(un niveau, correct). Les chemins (3)–(8) tiennent compte du cast : ils
assignent un scalaire PHP, jamais une string déjà `json_encode`.

**Ne pas changer ce cast au #1460** (impact large : tout `ocr_raw`).

### A4. Origine commune des 14 — requête (Robin)

Hypothèse instruite par le code : les 14 sont le **cohort #1453 --apply**
(valideur vide → azure_id du **déposant** via JOIN `users u ON u.id = df.user_id`).
La ventilation Magali COLLIN 8 / Sabrina BARTOLI 2 / … du prompt est alors
celle des **déposants**, pas d’un valideur tiers choisi à la saisie.

À confirmer par l’historique `backfill_validateur_deposant` (`Historisable`
L27–37, action L128–132 de la commande, table `facture_historique`).

```sql
SELECT
  df.id,
  df.uuid,
  df.statut,
  df.user_id,
  df.created_at,
  df.updated_at,
  JSON_UNQUOTE(JSON_EXTRACT(df.ocr_raw, '$.saisie.validateur_id')) AS brut,
  TRIM(BOTH CHAR(34) FROM JSON_UNQUOTE(JSON_EXTRACT(df.ocr_raw, '$.saisie.validateur_id'))) AS net,
  JSON_UNQUOTE(JSON_EXTRACT(df.ocr_raw, '$.saisie.validateur_email')) AS validateur_email,
  u.name AS deposant_nom,
  u.azure_id AS deposant_azure_id,
  (
    SELECT COUNT(*)
    FROM facture_historique fh
    WHERE fh.depot_facture_id = df.id
      AND fh.action = 'backfill_validateur_deposant'
  ) AS n_histo_1453,
  (
    SELECT MIN(fh2.created_at)
    FROM facture_historique fh2
    WHERE fh2.depot_facture_id = df.id
      AND fh2.action = 'backfill_validateur_deposant'
  ) AS premier_backfill_1453_at
FROM depot_factures df
INNER JOIN users u ON u.id = df.user_id
WHERE df.deleted_at IS NULL
  AND df.id IN (964, 1118, 1144, 1200, 1259, 1440, 1553, 1554, 1579, 1582, 1613, 1629, 1631, 1633)
ORDER BY df.id;
```

Lecture attendue (sans chiffre à inventer) : `n_histo_1453 >= 1` et
`net = deposant_azure_id` sur chaque ligne. Si une ligne a `n_histo_1453 = 0`,
alors un second écrivain (ré-enregistrement saisie d’une valeur déjà quotée)
est en jeu — le signaler avant le #1460.

Preuve de mécanisme (zéro ligne métier, coller tel quel) :

```sql
SELECT
  JSON_UNQUOTE(JSON_EXTRACT(
    JSON_SET(JSON_OBJECT('saisie', JSON_OBJECT()), '$.saisie.validateur_id', JSON_QUOTE('c437ff40-16dc-456c-b89b-f0bb9c48e00f')),
    '$.saisie.validateur_id'
  )) AS avec_json_quote,
  JSON_UNQUOTE(JSON_EXTRACT(
    JSON_SET(JSON_OBJECT('saisie', JSON_OBJECT()), '$.saisie.validateur_id', 'c437ff40-16dc-456c-b89b-f0bb9c48e00f'),
    '$.saisie.validateur_id'
  )) AS sans_json_quote;
```

Attendu : `avec_json_quote` conserve les guillemets ; `sans_json_quote` est l’UUID nu.

### A5. Défaut encore actif aujourd’hui ?

**Chemin utilisateur (saisie / upload / défaut déposant) : NON.** Les écrivains
Eloquent posent une string nue + cast `array`. Un nouveau dépôt créé par l’UI
n’est pas double-encodé.

**Commande #1453, branche MySQL : OUI, le code fautif est toujours dans
`origin/main`.** `validateurIdEstVide` (L164–177) ignore les 14 (valeur
non vide, même quotée) : un second `--apply` ne les réécrit pas. En revanche,
tout dépôt **encore vide** rattrapé par un futur `--apply` recevrait à nouveau
des quotes. Les tests sqlite ne protègent pas.

Les 14 lignes sont un **résidu historique** du `--apply` #1453, **plus** un
code CLI encore dangereux. Le #1460 doit **corriger le code et nettoyer les
données**, pas seulement les données.

---

## B. LA LECTURE

### B1. Lecteurs de `ocr_raw.saisie.validateur_id`

| Lecteur | Fichier | Lignes | Tolère les quotes |
|---------|---------|--------|-------------------|
| `CompleterSaisieFactureTaskSync::resolveAssignee` | `app/Services/Tasks/CompleterSaisieFactureTaskSync.php` | L77–86 | NON — délègue à `resolveUser` |
| Backfill #1458 | `BackfillCompleterSaisieFactureTasksCommand.php` | L91–96 | NON — même `resolveAssignee` ; message « valideur non résolu » |
| `FactureValidateurNv1AssignmentService::resolveUser` | L145–183 | `trim` seulement ; `where('azure_id', $ref)` ; regex UUID L224–229 exige `^[0-9a-f]{8}-…$` | NON |
| `whereSaisieValidateurEstUser` (#1450) | `DepotFactureController.php` L3414 | `trim(json_unquote(json_extract(...))) = ?` avec azure_id / id **nus** | NON |
| Repli déposant-si-vide (même méthode) | L3446–3451 | `json_unquote(...) in ('', 'null')` — la valeur quotée n’est **pas** vide | NON (les 14 sont « remplis ») |
| `applyBoiteDepotValidateurFilter` | L3381–3391 | appelle `whereSaisieValidateurEstUser` | NON |
| `DepotFactureValidateurEffectif::estDesigneDansSaisie` | L19–26 / `idVide` L122–135 | quotes ≠ vide / null | OUI au sens « désigné » (bloque le repli) |
| `DepotFactureValidateurEffectif::idEffectif` | L75–79 | renvoie la string **avec** quotes | échoue ensuite au `ctype_digit` / résolution |
| `resolveValidateurSelectPayload` | `DepotFactureController.php` L5780–5787 | `resolveUser` | NON — Select Graph ne pré-sélectionne pas |
| `FactureVisibility::applyDesignatedValidatorScope` | L321 | UNQUOTE = `users.id` (entier), pas azure_id | NON pour ces UUID |
| `FactureGlobaleReferentFilter` | L48 | `CAST(JSON_UNQUOTE(...) AS UNSIGNED)` | NON (UUID ≠ entier) |
| Front `appliquerValidateurEnregistrePourSelect` | `DepotFactureSaisieForm.vue` L1606–1615 | `String(storedId).includes('-')` encore vrai avec quotes, mais l’id ne matche pas Graph | Select cassé / option hors liste |

### B2. Filtre valideur #1450 — impact Magali COLLIN

Le filtre **est peuplé** (pas un mort-né) :

1. Back : défaut `a_completer = true` si le paramètre est absent
   (`DepotFactureController.php` L3175–3178, commentaire `#1450`).
2. Back : application réelle L3251–3263 (`elseif ($aCompleter) { $this->whereSaisieValidateurEstUser(...) }`).
3. Front : case « Mes factures à compléter » `Index.vue` L1280, v-model
   `filtreACompleter` L167 (`!== false` → coché par défaut), query `a_completer=1` L717.
4. `mes_depots` défaut `false` (L3171–3173 / L166). Donc l’arrivée sur la boîte
   = **uniquement** le filtre valideur, pas l’union déposant.

Sur une valeur double-encodée :

- `JSON_UNQUOTE` = `"c437ff40-…"` ≠ azure_id nu de Magali → branche id **rate**.
- `#1453` n’écrit **pas** `validateur_email` (test L77 `assertArrayNotHasKey('validateur_email')`) → branche email **rate** si l’email n’était pas déjà là.
- Repli déposant : valideur considéré **rempli** → **rate**.

**Oui : Magali COLLIN ne voit pas ses 8 dépôts dans la boîte filtrée « Mes factures à compléter ».** Idem Sabrina BARTOLI (2), Isabelle LANNES (2), Sandrine PORTALES (1), Manon KOELH (1), pour les ids du prompt. Ils réapparaissent seulement si « Voir mes dépôts » est coché (filtre `user_id`, L3260–3261), pas au titre de valideur.

Requête de preuve filtre (Robin, coller tel quel ; remplacer `:azure` et `:uid` par ceux de Magali lus en amont) :

```sql
SELECT df.id,
  JSON_UNQUOTE(JSON_EXTRACT(df.ocr_raw, '$.saisie.validateur_id')) AS brut,
  TRIM(BOTH CHAR(34) FROM JSON_UNQUOTE(JSON_EXTRACT(df.ocr_raw, '$.saisie.validateur_id'))) AS net,
  CASE
    WHEN TRIM(JSON_UNQUOTE(JSON_EXTRACT(df.ocr_raw, '$.saisie.validateur_id'))) = :azure THEN 'match_filtre_actuel'
    ELSE 'invisible_filtre_a_completer'
  END AS visibilite_filtre_1450
FROM depot_factures df
WHERE df.deleted_at IS NULL
  AND df.id IN (964, 1118, 1144, 1200, 1259, 1440, 1553, 1554, 1579, 1582, 1613, 1629, 1631, 1633)
  AND df.user_id = :uid;
```

Attendu : `visibilite_filtre_1450 = invisible_filtre_a_completer` tant que le nettoyage n’est pas fait.

### B3. Durcir la lecture ?

**Avis : oui, un trim défensif des guillemets enbordant dans `resolveUser` (un seul point PHP), en plus de corriger l’écriture et de nettoyer.** Motifs :

- les lecteurs PHP (#1458, Select, NV1) passent presque tous par `resolveUser` ;
- un résidu oublié par le WHERE de nettoyage (quote d’un seul côté, espace) resterait silencieux ;
- ne pas modifier le SQL du filtre #1450 dans le même geste que le nettoyage : après JSON_SET ciblé, le filtre actuel redevient juste. Changer le SQL #1450 sans besoin crée un risque de collision avec l’acquis.

Ne pas implémenter ici.

---

## C. LE DÉFAUT JUMEAU DES AVIS D’IMPOSITION

### C1. « Array to string conversion » L427

**Sur HEAD actuel, L427 n’est plus ce code.** `AvisImpositionController.php` L426–431 est `peutAcceder()` (`m365GroupNames()` brut).

La cause **documentée #1435** (fiche L45–50) : `valeursEquivalentes` faisait
`(string) $array` quand `$validator->validated('valeur')` renvoyait tout le
tableau validé `['champ' => …, 'valeur' => …]`. Laravel 13
`Illuminate\Validation\Validator::validated()` (vendor L644) **ne prend aucun
argument** et ignore `'valeur'`.

Aujourd’hui `valeursEquivalentes` (L684–687) court-circuite si `is_array` /
`is_object`. Le log prod du 04/09 à « L427 » désigne **l’ancien numéro de
ligne**, pas le fichier actuel.

### C2. Code #1435 qui écrivait `{champ, valeur}`

Avant le fix : `$validator->validated('valeur')` → array entier →
`setAttribute($champ, $array)` → MySQL sérialise l’objet JSON dans un VARCHAR
(`interlocuteur`, `departement`, `commune` assez longs) ou SQLSTATE 1406 sur
`tlvh` varchar(10)
(`database/migrations/2026_09_03_150000_create_avis_impositions_table.php` L17).

Front inchangé et **correct** : `InlineEditableField.vue` L113
`JSON.stringify({ champ: props.champ, valeur: draft.value })`.

### C3. Mutualisé avec le dépôt de facture ?

**NON.**

Preuves :

- Dépôt : aucun `InlineEditableField` ; POST `validateur_id` scalaire ; écriture
  fautive = `JSON_QUOTE` dans une commande artisan.
- Avis : PATCH `{champ, valeur}` + `Validator::validated()` sans clé.
- Le composant `InlineEditableField.vue` est partagé avec parcelle, dossier,
  offre, juridique — **pas** avec le dépôt facture.
- Les autres `updateChamp` extraient `$validated['valeur']` après
  `$request->validate(['valeur' => …])` (Parcelle L586–590, Dossier L1076–1079,
  Offre L528–531, Occupation L112–116, Bati L99–103) : FormRequest/validate
  tableau, **pas** `Validator::validated('valeur')`.
- `DemandeJuridiqueController` L255 : `$request->validated('valeur')` sur un
  **FormRequest** (`FormRequest::validated($key = null)` vendor L362) — API
  différente de `Validator::validated()`. Hors cause #1435.

Même *famille* (scalaire attendu, structure JSON écrite) ; **pas** le même
helper, **pas** le même endpoint. Le #1460 dépôt ne répare pas les avis.

### C4. Recensement avis — requête (Robin)

Colonnes inline whitelist : `AvisImposition::champsInlineAutorises()` L91–117.
Les DECIMAL ne peuvent pas contenir `{"champ":…}` (échec 1406 à l’écriture).
Cibler VARCHAR/TEXT.

```sql
SELECT id, 'structure' AS col, structure AS val
FROM avis_impositions WHERE deleted_at IS NULL AND structure LIKE '%"champ":%'
UNION ALL
SELECT id, 'departement', departement
FROM avis_impositions WHERE deleted_at IS NULL AND departement LIKE '%"champ":%'
UNION ALL
SELECT id, 'tlvh', tlvh
FROM avis_impositions WHERE deleted_at IS NULL AND tlvh LIKE '%"champ":%'
UNION ALL
SELECT id, 'adresse', adresse
FROM avis_impositions WHERE deleted_at IS NULL AND adresse LIKE '%"champ":%'
UNION ALL
SELECT id, 'commune', commune
FROM avis_impositions WHERE deleted_at IS NULL AND commune LIKE '%"champ":%'
UNION ALL
SELECT id, 'interlocuteur', interlocuteur
FROM avis_impositions WHERE deleted_at IS NULL AND interlocuteur LIKE '%"champ":%'
UNION ALL
SELECT id, 'numero_avis', numero_avis
FROM avis_impositions WHERE deleted_at IS NULL AND numero_avis LIKE '%"champ":%'
UNION ALL
SELECT id, 'cdif', cdif
FROM avis_impositions WHERE deleted_at IS NULL AND cdif LIKE '%"champ":%'
UNION ALL
SELECT id, 'rp_demande', rp_demande
FROM avis_impositions WHERE deleted_at IS NULL AND rp_demande LIKE '%"champ":%'
UNION ALL
SELECT id, 'dgvmt_demande', dgvmt_demande
FROM avis_impositions WHERE deleted_at IS NULL AND dgvmt_demande LIKE '%"champ":%'
UNION ALL
SELECT id, 'observation', observation
FROM avis_impositions WHERE deleted_at IS NULL AND observation LIKE '%"champ":%'
UNION ALL
SELECT id, 'numero_avis_2024', numero_avis_2024
FROM avis_impositions WHERE deleted_at IS NULL AND numero_avis_2024 LIKE '%"champ":%'
UNION ALL
SELECT id, 'commentaire', commentaire
FROM avis_impositions WHERE deleted_at IS NULL AND commentaire LIKE '%"champ":%'
ORDER BY id, col;
```

Ne pas se limiter à `id = 1`.

### C5. Défaut avis encore actif ?

**Code d’écriture : NON** depuis #1435 (`317a10c3`). HEAD :

```
AvisImpositionController.php L119–146
extrait $request->input('valeur') via AvisImposition::validerPatchInline
refuse is_array / is_object en 422
setAttribute($champ, $valeur) scalaire seulement
```

Les écritures loguées du 03/09 sont **antérieures** (ou contemporaines) au
bug. Les lectures en erreur du 04/09 à « L427 » = ancien binaire **ou**
affichage de VARCHAR déjà corrompus (le JSON s’affiche en cellule, ce n’est
plus un `(string) $array` sur HEAD). **Données résiduelles possibles** — C4
le dira. Pas de régression code à refaire dans le lot dépôt.

---

## D. PÉRIMÈTRE DU MÊME DÉFAUT AILLEURS

### D1. Même helper / même endpoint

`JSON_QUOTE` dans `app/` : **un seul fichier** —
`BackfillDepotFactureValidateurCommand.php` L215. Pas d’autre `JSON_SET`
applicatif.

`InlineEditableField` : Avis, Parcelles/Show, Dossiers/Show, Offres/Show,
Juridique/Show. Backends cités en C3 extraient le scalaire `valeur`. Risque
résiduel : uniquement d’anciennes données avis (C4), pas un écrivain dépôt.

### D2. Détection générique — requêtes (Robin)

Dépôts : clé JSON dont `JSON_UNQUOTE` commence **et** finit par un guillemet
littéral (CHAR(34)). Pas de `GROUP BY` (only_full_group_by).

```sql
SELECT
  df.id,
  df.statut,
  JSON_UNQUOTE(JSON_EXTRACT(df.ocr_raw, '$.saisie.validateur_id')) AS brut
FROM depot_factures df
WHERE df.deleted_at IS NULL
  AND JSON_EXTRACT(df.ocr_raw, '$.saisie.validateur_id') IS NOT NULL
  AND LEFT(JSON_UNQUOTE(JSON_EXTRACT(df.ocr_raw, '$.saisie.validateur_id')), 1) = CHAR(34)
  AND RIGHT(JSON_UNQUOTE(JSON_EXTRACT(df.ocr_raw, '$.saisie.validateur_id')), 1) = CHAR(34)
ORDER BY df.id;
```

Autres clés `saisie` (échantillon, une ligne par dépôt si au moins une clé
atteinte) — à lancer seulement si la précédente remonte plus que les 14 ids
connus, pour voir si le défaut s’est diffusé via ré-enregistrement :

```sql
SELECT df.id, k.k AS cle, JSON_UNQUOTE(JSON_EXTRACT(df.ocr_raw, CONCAT('$.saisie.', k.k))) AS brut
FROM depot_factures df
INNER JOIN JSON_TABLE(
  JSON_KEYS(JSON_EXTRACT(df.ocr_raw, '$.saisie')),
  '$[*]' COLUMNS (k VARCHAR(64) PATH '$')
) AS k
WHERE df.deleted_at IS NULL
  AND df.ocr_raw IS NOT NULL
  AND JSON_EXTRACT(df.ocr_raw, '$.saisie') IS NOT NULL
  AND LEFT(JSON_UNQUOTE(JSON_EXTRACT(df.ocr_raw, CONCAT('$.saisie.', k.k))), 1) = CHAR(34)
  AND RIGHT(JSON_UNQUOTE(JSON_EXTRACT(df.ocr_raw, CONCAT('$.saisie.', k.k))), 1) = CHAR(34)
ORDER BY df.id, k.k;
```

Avis : requête C4 (`LIKE '%"champ":%'`).

---

## E. REMÉDIATION — PROPOSER, PAS EXÉCUTER (#1460)

### E1. Correction du point d’écriture

Fichier unique : `app/Console/Commands/BackfillDepotFactureValidateurCommand.php`

- Branche MySQL L215 : **retirer `JSON_QUOTE(...)`**. Passer directement
  `IF(u.azure_id IS NOT NULL AND TRIM(u.azure_id) <> '', u.azure_id, CAST(u.id AS CHAR))`
  à `JSON_SET` (MySQL quote une fois, comme la doc).
- Ne pas toucher la branche sqlite (déjà saine).
- Ajouter un test **source** qui refuse `JSON_QUOTE` dans le SQL MySQL (les
  tests sqlite n’auraient pas vu le bug).
- Ne **pas** modifier `DepotFacture::$casts['ocr_raw']`.
- Ne **pas** modifier `applyDepotSaisieFromValidated` / `ensureValidateurDeposantParDefaut`
  / #1450 / #1453 signature / #1458 types de tâches.

Option lecture (B3) : dans `FactureValidateurNv1AssignmentService::resolveUser`,
après `trim`, si la ref a longueur ≥ 2 et commence et finit par `"`, strip une
fois. N’écrit rien. N’impacte pas les 18 tâches existantes.

### E2. Commande de nettoyage des 14

Signature littérale (namespace = token avant le premier `:`, leçon #1453
« depot namespace ») :

`depot-factures:normalize-validateur-id {--apply}`

- Dry-run par défaut ; `--apply` pour écrire.
- Idempotente : WHERE `LEFT(JSON_UNQUOTE(JSON_EXTRACT(ocr_raw, '$.saisie.validateur_id')), 1) = CHAR(34)`
  **et** `RIGHT(...) = CHAR(34)` ; une valeur déjà saine n’est jamais réécrite.
- Écriture = `JSON_SET(ocr_raw, '$.saisie.validateur_id', TRIM(BOTH CHAR(34) FROM JSON_UNQUOTE(...)))`
  **sans** `JSON_QUOTE`, **sans** réassigner tout `ocr_raw` en PHP (leçon #1453 /
  incident backfill qui écrasait ~25 clés).
- Historique `system: true`, action dédiée (ex. `normalize_validateur_id_quotes`),
  pas `backfill_validateur_deposant`.
- Ne pas relancer `depot-factures:backfill-validateur --apply` tant que E1 n’est
  pas déployé.

### E3. Backfill #1458 après nettoyage

**Lecture confirmée.** `BackfillCompleterSaisieFactureTasksCommand.php` L77–81 :

```
$idempotencyKey = $typeCode.':'.$depot->getMorphClass().':'.$depot->getKey();
if (Task::query()->where('idempotency_key', $idempotencyKey)->exists()) {
    $dejaExistante++;
    continue;
}
```

`TaskSyncService::idempotencyKey` L303–305 = `completer_saisie_facture:{morph}:{id}`.
Les 18 tâches déjà créées sont sautées. Après strip des quotes, `resolveAssignee`
réussit → `--apply` crée les tâches manquantes des 14, sans dupliquer les 18.

`upsertFromEvent` L132–135 `updateOrCreate` sur la même clé : un second passage
ne clone pas.

### E4. Avis d’imposition si C3 OUI

C3 = **NON**. Pas dans le #1460 dépôt.

Lot distinct (ex. #1461) : SQL de réparation des VARCHAR C4 (extraire
`JSON_UNQUOTE(JSON_EXTRACT(val, '$.valeur'))` seulement si `val LIKE '%"champ":%'`),
dry-run manuel Robin, **sans** retoucher le contrôleur déjà corrigé #1435.

### E5. Ordre #1460 et nombre de lots

**Deux lots.**

1. **#1460 (dépôt)** — un commit : fix `JSON_QUOTE` + commande
   `depot-factures:normalize-validateur-id` + test source anti-`JSON_QUOTE` +
   fiche + journal si impact user (filtre Magali redevient juste — **oui**,
   entrée journal vulgarisée). Optionnel : trim défensif `resolveUser`.
   Puis OVH : `git pull` / `optimize:clear` / dry-run normalize / `--apply` /
   `taches:backfill-completer-saisie --apply`. Ne pas relancer le backfill
   #1453. Ne pas toucher aux 18 tâches.
2. **#1461 (avis)** — après C4 : nettoyage données uniquement si des lignes
   restent. Code d’écriture déjà sain.

Cast `ocr_raw` : ne pas toucher (clause d’arrêt respectée).

---

## DÉPLOIEMENT-TEST

Aucun déploiement applicatif (diagnostic). Après push de cette fiche :

```
git pull origin main
```

Robin : lancer les SQL A4, preuve `avec_json_quote` / `sans_json_quote`, C4, D2.
Ne pas `--apply` de commande dans ce lot.

## LEÇON

`JSON_SET(doc, path, JSON_QUOTE(str))` sur MySQL **double-quote** : `JSON_SET`
quote déjà les chaînes SQL. Les tests sqlite d’un backfill JSON ne prouvent
rien pour la prod MySQL 8.4. Un filtre JSON `JSON_UNQUOTE(...) = azure_id` est
« peuplé » et pourtant sourd à une valeur `"uuid"` — les 14 dépôts existent
mais disparaissent de « Mes factures à compléter ».
