---
suivi: 1949
date: 2026-10-16
sujet: Diagnostic détection doublons dépôt — non-relance / n° partiel
chantier: factures / doublons
type: diagnostic
statut: poussé
hash: 01aed891a
fichiers:
  - docs/suivi/SUIVI_1949_diag_doublon_detection_relance.md
---

# SUIVI #1949 — DIAGNOSTIC détection doublons (1214 / 2469)

## Fiches lues

- #1413 LEÇON : flag `doublon_detecte` stocké à l’OCR ; liste matches recalculée à la volée **seulement si le flag est vrai** ; contrôle humain = empreinte.
- #1444 : bandeau UI dépend de `doublon_detecte` ; ne change pas la détection.
- #1845 (import FE) : chemin séparée `marquerDoublons` (égalité stricte n° + fournisseur ; « l’électronique prime »).

## PROMPT ENVOYÉ

PHASE 1 diagnostic uniquement : où/quand détection, cause 1214/2469, comportement, FE, requête contrôle prod, proposition PHASE 2. Aucune modif données / code métier.

Complément Robin : le contrôle 3/4 doit aussi tourner à la **demande de validation NV1** (après complétion + envoi en validation). Points attendus : OCR / saisie (changement d’un des 4 champs), demande NV1, avant comptabilisation, avant paiement. Indiquer le point d’entrée exact NV1 et si un contrôle doublon y existe déjà.

## SYNTHÈSE

### 1. Où et quand la détection tourne aujourd’hui

| Moment | Fichier:ligne | Écrit `doublon_detecte` ? / contrôle 3/4 ? |
| --- | --- | --- |
| OCR Claude (dépôt PDF) | `DepotFactureClaudeAnalysisService.php:75-78`, écriture `:112-113` | Oui (detector #900) |
| Création Outlook (XML) | `OutlookDepotCreation.php:142-152` | Oui (sans `fournisseur_id` dans le payload) |
| Import FE (#1845) | `FactureElectroniqueImport.php:558`, `marquerDoublons:564-635` | Oui (chemin **séparé**, pas le detector #900) |
| Enregistrement saisie `update` | `DepotFactureController.php:2226+` / `applyDepotSaisieFromValidated` (~1979–2174) | **Non** — met à jour `fournisseur_id`, n°, date, montants **sans** relancer |
| **Demande de validation NV1** | `DepotFactureController::valider` **:2369** (route `POST /depot-factures/{uuid}/valider`, `routes/web.php:508` ; front `envoyerEnValidation` → `submitValidation` → ce POST, `DepotFactureSaisieForm.vue:9021` / `:8955`) | **Pas de contrôle 3/4.** Seul appel doublon : `refuserSiDoublonDeFactureElectronique` **:2375** → `1799-1810` : refuse **uniquement** si `doublon_de_id` pointe vers un dépôt **FE** gagnant. Aucun appel à `DepotFactureDuplicateDetector`. |
| Validation workflow déjà engagé | `validerWorkflowEngage` `:2297` | Idem : `refuserSiDoublonDeFactureElectronique` seulement |
| Avant comptabilisation | `ComptabilisationController::assertDepotPretPourEnvoiApbill` `:1151-1207` | **Ne pose pas** le flag ; blocage seulement si n° **égal** (normalizeForCompare) + même fournisseur + même programme + déjà `intacct_bill_id` ; sinon log warning si n° égal hors compta |
| Avant paiement | `PaiementController` / services paiement | **Aucun** contrôle doublon |

#### Demande NV1 — détail (complément Robin)

- **Point d’entrée exact** : `app/Http/Controllers/DepotFactureController.php:2369` — méthode `valider()`. C’est l’envoi après complétion de la saisie (« Facture envoyée en validation. », création `Facture` + `WorkflowValidation` niveau `nv1` en attente, statut dépôt/facture `en_validation_nv1` sauf auto-NV1 / déjà réglée).
- **Contrôle doublon existant** : **non** pour la règle 3/4. Uniquement le garde-fou FE `refuserSiDoublonDeFactureElectronique` (ligne 2375). La recopie `FactureDoublonControle::attributsRecopieDepuisDepot` à la création de la facture (`:2623`) **ne relance pas** la détection : elle copie un contrôle humain déjà posé.
- **PHASE 2** : ajouter ici un contrôle bloquant 3/4 (après validation des champs, avant `DB::transaction` / création facture), sauf si `FactureDoublonControle::isControleActif`.

Moteur classique : `DepotFactureDuplicateDetector` (#900) :

- **SOCLE** (`isSocleMatch:153-163`) : n° `normalizeForCompare` **identique** ET montant TTC ±0,01 € (fournisseur ignoré).
- **FILET** (`filetScore:169-188`) : score ≥ 3 parmi {date, n° égal, `fournisseur_id`, montant ±0,01}.
- Normalisation n° : `NumeroFactureFournisseurNormalizer::normalizeForCompare` (`:39-50`) = strip `[^A-Za-z0-9]` + upper. **Pas** d’inclusion, **pas** de strip suffixe « SIT 4 » / « situation ».
- Candidats SQL (`candidateDepots:264-278`) : filtre large montant OU date OU fournisseur ; **n’exclut pas** `statut = rejete`.

Rôles des colonnes :

- `doublon_detecte` / `doublon_de_id` : résultat figé au moment OCR (ou Outlook / FE). **Seul levé d’alerte UI** (`DepotFactureDoublonPayload:17-19` : si flag faux → liste vide, même si un recalcul live trouverait des matches).
- `doublon_controle*` (#1413) : contrôle **humain** « ce n’est pas un doublon » **après** détection. `= 0` / false = **jamais marqué par un humain**, pas « détection auto jamais exécutée ». La détection auto, c’est `doublon_detecte`.

Si fournisseur vide à l’OCR : `fournisseur_id` null dans le fingerprint → le critère fournisseur ne compte pas. SOCLE peut encore matcher (n°+montant). FILET souvent ≤ 2 (date+montant) → **pas de flag**. Quand le fournisseur est saisi plus tard, **rien ne relance** → flag reste à 0. Hypothèse Robin **confirmée** par le code.

### 2. Cause 1214 / 2469

Données déclarées :

| id | n° | date | TTC | fournisseur_id | statut | créé |
| --- | --- | --- | --- | --- | --- | --- |
| 1214 | `0938M-26 SIT 4` | 30/07/2026 | 5520 | 2529 | paye | 30/07 |
| 2469 | `0938M-26` | 30/07/2026 | 5520 | 2529 | a_comptabiliser | 09/10 |

Normalisation actuelle :

- `0938M-26 SIT 4` → `0938M26SIT4`
- `0938M-26` → `0938M26`
- **Inégaux** → SOCLE non ; critère n° FILET non ; gate compta (égalité stricte) **non**.

Score FILET **avec** fournisseur aujourd’hui : date + fournisseur + montant = **3** → **devrait** matcher via filet si `detect()` était rappelé.

Score **sans** fournisseur (OCR) : date + montant = **2** → **pas** de match.

Donc pour cette paire : (a) n° non équivalents sous la règle actuelle → pas de SOCLE / pas de blocage compta ; (b) FILET 3/4 aurait suffi **dès que** `fournisseur_id` est connu, mais le flag n’est écrit qu’à l’OCR et n’est jamais rafraîchi à l’enregistrement → les deux restent `doublon_detecte = 0`, `doublon_controle = 0`. Instant T probable pour 2469 (OCR ~09/10) : fournisseur encore vide ou non résolu → score 2 ; 1214 déjà payé avec les 4 champs remplis, invisible pour le flag.

### 3. Comportement actuel si doublon détecté

- Bandeau alerte saisie / fiche (`DepotFactureSaisieForm`, payload `doublons_potentiels`) : alerte, Comparer / Voir le doublon.
- Case « J’ai vérifié : ce n’est pas un doublon » (#1413) : auteur + date + empreinte ; réversible (auteur ou groupes retrait).
- **Ne bloque pas** validation / envoi en compta par le seul flag (sauf FE `doublon_de_id` → gagnant électronique).
- Avant APBILL : blocage seulement n° **strictement** égal + fournisseur + programme + déjà comptabilisé ; sinon warning log.
- Paiement : **aucun** garde-fou doublon.

### 4. Factures électroniques

**Non** : pas le même moteur. `FactureElectroniqueImport::marquerDoublons` exige fournisseur + n° `normalizeForCompare` **égal** ; marque les classiques non comptabilisés comme doublons du FE (« électronique prime ») ; si un classique est déjà comptabilisé, marque le FE. Pas de FILET 3/4, pas d’inclusion de n°. La paire 1214/2469 (n° partiels) **échapperait aussi** à ce chemin.

### 5. Règle n° proposée (PHASE 2 — à valider Robin)

Après `normalizeForCompare` (strip non-alphanum + upper) :

1. égalité stricte → match n° ;
2. sinon strip suffixes situation : `(SIT|SITUATION)\d*` en fin de clé → re-comparer ;
3. sinon inclusion : le plus court est préfixe / contenu du plus long **et** longueur du plus court ≥ 6 (anti faux positifs courts).

Exemple : `0938M26SIT4` vs `0938M26` → (2) ou (3) → match n°.

### 6. Requête de contrôle prod (lecture seule)

À exécuter en lecture seule sur MySQL prod (MySQL 8+ : `REGEXP_REPLACE`). N’écrit rien. Priorise les paires payée / non payée.

```sql
-- SUIVI #1949 — paires dépôt non rejetés, ≥ 3/4 critères (règle actuelle + inclusion n°)
-- Critères : fournisseur_id, n° (norm + inclusion), date_facture, montant_ttc ±0.01
WITH base AS (
  SELECT
    d.id,
    d.statut,
    d.fournisseur_id,
    d.numero_facture,
    UPPER(REGEXP_REPLACE(IFNULL(d.numero_facture, ''), '[^A-Za-z0-9]', '')) AS num_norm,
    d.date_facture,
    d.montant_ttc,
    d.intacct_bill_id,
    d.doublon_detecte,
    d.doublon_controle,
    d.created_at,
    (d.statut = 'paye' OR (d.intacct_bill_id IS NOT NULL AND TRIM(d.intacct_bill_id) <> '')) AS est_paye_ou_compta
  FROM depot_factures d
  WHERE d.statut <> 'rejete'
    AND d.montant_ttc IS NOT NULL
)
SELECT
  a.id AS id_a,
  b.id AS id_b,
  a.statut AS statut_a,
  b.statut AS statut_b,
  a.est_paye_ou_compta AS paye_a,
  b.est_paye_ou_compta AS paye_b,
  a.fournisseur_id,
  a.numero_facture AS numero_a,
  b.numero_facture AS numero_b,
  a.num_norm AS num_norm_a,
  b.num_norm AS num_norm_b,
  a.date_facture AS date_a,
  b.date_facture AS date_b,
  a.montant_ttc AS ttc_a,
  b.montant_ttc AS ttc_b,
  a.intacct_bill_id AS bill_a,
  b.intacct_bill_id AS bill_b,
  a.doublon_detecte AS detecte_a,
  b.doublon_detecte AS detecte_b,
  (
    (a.fournisseur_id IS NOT NULL AND a.fournisseur_id = b.fournisseur_id)
    + (a.date_facture IS NOT NULL AND a.date_facture = b.date_facture)
    + (ABS(a.montant_ttc - b.montant_ttc) <= 0.01)
    + (
         a.num_norm <> '' AND b.num_norm <> ''
         AND (
           a.num_norm = b.num_norm
           OR a.num_norm LIKE CONCAT('%', b.num_norm, '%')
           OR b.num_norm LIKE CONCAT('%', a.num_norm, '%')
         )
         AND CHAR_LENGTH(LEAST(a.num_norm, b.num_norm)) >= 6
      )
  ) AS score_3_sur_4,
  CASE
    WHEN a.est_paye_ou_compta XOR b.est_paye_ou_compta THEN 0
    ELSE 1
  END AS priorite_paye_vs_non
FROM base a
JOIN base b ON a.id < b.id
WHERE
  (
    (a.fournisseur_id IS NOT NULL AND a.fournisseur_id = b.fournisseur_id)
    + (a.date_facture IS NOT NULL AND a.date_facture = b.date_facture)
    + (ABS(a.montant_ttc - b.montant_ttc) <= 0.01)
    + (
         a.num_norm <> '' AND b.num_norm <> ''
         AND (
           a.num_norm = b.num_norm
           OR a.num_norm LIKE CONCAT('%', b.num_norm, '%')
           OR b.num_norm LIKE CONCAT('%', a.num_norm, '%')
         )
         AND CHAR_LENGTH(LEAST(a.num_norm, b.num_norm)) >= 6
      )
  ) >= 3
ORDER BY priorite_paye_vs_non ASC, score_3_sur_4 DESC, a.id, b.id;
```

Contrôle ciblé 1214/2469 :

```sql
SELECT id, statut, fournisseur_id, numero_facture,
       UPPER(REGEXP_REPLACE(IFNULL(numero_facture,''),'[^A-Za-z0-9]','')) AS num_norm,
       date_facture, montant_ttc, intacct_bill_id,
       doublon_detecte, doublon_controle, doublon_de_id, created_at
FROM depot_factures
WHERE id IN (1214, 2469);
```

## PROPOSITION PHASE 2 (ne pas coder ici)

Points de contrôle attendus (Robin) : **OCR / saisie** (dès qu’un des 4 champs change) → **demande NV1** (`valider`) → **avant comptabilisation** → **avant paiement**.

1. **Règle** : conserver FILET ≥ 3/4 ; enrichir le critère n° (égalité + strip situation + inclusion longueur ≥ 6). Aligner SOCLE et gate compta / NV1 / paiement sur la même comparaison n°.
2. **Déclenchement** : extraire un service `relancerDetectionDoublon(DepotFacture)` appelé à chaque changement de l’un des 4 champs (OCR, `applyDepotSaisieFromValidated` / `update`) ; **bloquant** à la demande NV1 (`DepotFactureController::valider` après validate, avant transaction), avant compta (`assertDepotPretPourEnvoiApbill`) et avant paiement (`PaiementController`) si match et contrôle humain inactif.
3. **UI / déblocage** : conserver bandeau + case #1413 (empreinte) ; ne pas auto-annuler un contrôle humain sauf empreinte divergente (déjà le cas).
4. **FE** : faire passer `marquerDoublons` par le même comparateur n° (ou appeler le detector) pour ne plus rater les n° partiels ; garder « électronique prime » ; conserver `refuserSiDoublonDeFactureElectronique` en plus du 3/4.
5. **Historique** : commande artisan lecture + **écriture limitée** aux seuls `doublon_detecte` / `doublon_de_id` (jamais statut, jamais payé qui recule) ; dry-run d’abord ; sortir la liste des paires pour revue compta.
6. **Fichiers probables** : `DepotFactureDuplicateDetector`, `NumeroFactureFournisseurNormalizer`, `DepotFactureClaudeAnalysisService`, `DepotFactureController` (`update` + **`valider`**), `ComptabilisationController`, `PaiementController`, `FactureElectroniqueImport`, tests unitaires detector + source, éventuellement commande `erp:relancer-doublons-depot`.
7. **Risques** : faux positifs inclusion (mitiger seuil longueur) ; perf candidats (déjà large) ; FE vs classique déjà traités ; ne pas toucher CIE / flux paiement hors garde-fou.
8. **Tests** : 1214/2469 (fixture n° SIT) ; fournisseur vide puis saisi → flag bascule ; **blocage envoi NV1** ; blocage compta/paiement ; contrôle humain conserve ; CIE=`grep -c cie_effacement_confirme` = 1 ; create classique / ExtBill inchangés.

## DÉPLOIEMENT-TEST

Diagnostic only — pas de déploiement applicatif. Exécuter la requête SQL en lecture seule sur prod, remonter le résultat (surtout paires payée / non payée).

## LEÇON

`doublon_detecte` est un **instantané OCR**, pas un état vivant. Sans relance à chaque changement des 4 champs **ni à la demande NV1** (`valider:2369`, aujourd’hui sans detector 3/4), un fournisseur renseigné après coup (ou un n° « 0938M-26 » vs « 0938M-26 SIT 4 ») laisse passer un second dépôt jusqu’à la compta — et la gate compta exige encore l’égalité stricte du n°, donc rate aussi cette paire. `doublon_controle = 0` ≠ « détection jamais jouée » : c’est l’absence de validation humaine.
