---
suivi: 1549
date: 2026-09-15
sujet: Diagnostic — annuler une validation NV1
chantier: factures / workflow / validation-nv1
type: diagnostic
statut: poussé
hash: 28f2c516
fichiers:
    - docs/suivi/SUIVI_1549_diag_annuler_validation_nv1.md
---

# SUIVI #1549 — PHASE 1 diagnostic : annuler une validation NV1

## PROMPT ENVOYÉ

Diagnostic seul. Un valideur NV1 doit pouvoir annuler sa validation si
NV2 n’a pas eu lieu et si la facture n’est pas comptabilisée. Retour
en NV1 à valider (pas en saisie). Motif obligatoire. Qui : le valideur
NV1 lui-même, GS_COMPTABILITE, GS_DIRECTION_GENERALE. Point dur : le
NV1 automatique (déposant = valideur) se rejouerait-il au retour ?
Retracer la mécanique, ce qu’il faudrait défaire, les conditions
d’ouverture, les droits (m365GroupNames brut), la traçabilité, le
placement UI, le volume SQL. Aucune migration, aucun code applicatif.

## BASE GIT

HEAD = `origin/main` = `e42902d6` (chore hash fiche #1548).
Working tree propre hors `hectareenvol.sql` (non commité).
Aucun rebase. Aucun fichier applicatif modifié.

## FICHES LUES

- **#1535 LEÇON** — libération RG acquise dès NV1+ via
  `FactureStatutService::etatRetenue` (pipeline `nv1_en_cours` inclus).
  Plafond = MRES encore détenue, pas `demandes_liberation_rg`.
  Impact : un retour en « NV1 à valider » **reste dans NV1+** ; la
  libération ne sort pas du plafond tant qu’on ne revient pas en
  saisie (hors périmètre).
- **#1534 LEÇON** — miroir `retenues_garantie` écrit à la
  comptabilisation, pas à NV1. Gate::before + `hasAnyM365Group`
  faussent les tests admin.
- **#1520 / #1457 / #1508 LEÇON** — pas de colonne
  `depot_factures.validateur_id` ; le désigné vit dans
  `ocr_raw.saisie.validateur_id` (azure_id ou `users.id`, historique
  double-encodé #1459). La preuve d’acte NV1 est
  `workflow_validations`.
- **#1486 LEÇON** — NV2 seulement si `programmes.enseigne_id` →
  `enseignes.code = ENVOL`. FG et hors ENVOL : skip NV2, statut
  `a_comptabiliser` + WV `mise_paiement` en_attente.
- **#1489 LEÇON** — `deja_reglee` clôture les WV en `sans_objet`
  sans valider. Frise saute NV1/NV2.
- **#1188 / #1037** — auto-NV1 à l’envoi dépôt via
  `creerWorkflowsApresDepotAutoNv1`, marqueur
  `validation_automatique_deposant`.
- **#1322 / #1410** — droits via `m365GroupNames()` brut +
  `abort_unless`. Jamais `hasAnyM365Group` / Gate.

Aucune fiche #1548 n’a changé l’approche workflow.

---

## 1. LA MÉCANIQUE DE VALIDATION NV1

Deux familles, pas une.

### 1.1 Clic « Valider » sur la fiche (et équivalents)

Entrées :

| Entrée | Fichier | Lignes | `withDepotHistorique` |
|--------|---------|--------|------------------------|
| Fiche programme / fiche FG | `FactureController::processValider` | L1182–1248 | **false** (L1224) |
| Saisie déjà engagée | `DepotFactureController::validerWorkflowEngage` | L2038–2107, appel L2060–2065 | **false** |
| Masse `/validations` | `ValidationController::validerFacture` | L293–333 | **true** (L330) |
| Masse globale | `FactureValidationService::validateGroupe` | L248–273 | **true** |

Cœur unique : `FactureValidationService::validatePendingStep`
(`app/Services/FactureValidationService.php` L48–180).

Séquence à l’intérieur de `DB::transaction` (L71–138) :

1. Première ligne `workflow_validations` en `statut = en_attente`
   ordonnée par `id` (L85–88). Si ce n’est ni `nv1` ni `nv2` → ignore
   (L100–104).
2. Droit : `FactureValidationPermissions::canValidateNiveau`
   (L106–110) — voir §4.
3. Garde dépassement marché #1295 (L112).
4. Écriture WV (L114–120) :
   - `statut` → `valide`
   - `date_validation` → now
   - `validateur_id` → `users.id` de l’acteur
     (`WorkflowValidationStepPresenter::validateurIdDepuisUser` L193–196)
   - `commentaire` → commentaire libre du formulaire (nullable)
   - flags blocage vidés
5. Si niveau `nv1` : `FactureWorkflowApresNv1Service::apresNv1Valide`
   (`app/Services/FactureWorkflowApresNv1Service.php` L19–38).

`apresNv1Valide` bifurque :

- **Skip NV2** (FG `programme_id` null, ou enseigne ≠ ENVOL) —
  `FacturePasseParValidationNv2::passeDirectementAComptabiliserApresNv1`
  L19–26 + `FactureEstFraisGeneraux` L11–16 :
  crée WV `mise_paiement` / `en_attente` (L129–137), pose
  `factures.statut = a_comptabiliser`, sync dépôt
  `DepotFacture::STATUT_A_COMPTABILISER`. Retour
  `{notify_nv2: false, notify_comptable: true}`.
- **ENVOL** : crée WV `nv2` / `en_attente` (L27–33), pose
  `factures.statut = en_validation_nv2`, sync dépôt
  `en_validation_nv2`. Retour `{notify_nv2: true, notify_comptable: false}`.

Hors transaction (L152–177) :

- Ferme la tâche Mes tâches NV1
  (`ValidationFactureNv1WorkflowHooks::closeAfterNv1Action` L152–156).
- Si `withDepotHistorique` : `syncDepotAfterValidation` (L166–168,
  L301–320) — **re-écrit** le statut dépôt (redondant avec
  `syncDepotStatut`) **et** `logHistorique('valide_nv1')`. Sur la
  fiche, cet historique **n’est pas posé**.
- Si `notify_nv2` : `NotificationFactureService::notifierValidateurNv2`
  (L170–172) — e-mail **désactivé** (Log::info L101–104), mais
  assigne `validateur_id` NV2 et **crée la tâche NV2**
  (`ValidationFactureNv2WorkflowHooks::upsertAfterNv2Entry` L127–135).
- Si `notify_comptable` : `AlerteService::factureAComptabiliser` +
  `notifierComptable`. Alerte **no-op** (`GENERATION_ACTIVE = false`,
  `AlerteService.php` L34 + L1087–1089). E-mail comptable **désactivé**
  (L143–146).

`FactureObserver` (`app/Observers/FactureObserver.php` L15–32) : au
`created` / `updated` de `statut` (parmi d’autres), écrit
`MarcheHistorique::logFacture`. **Aucun auto-NV1.**

Calculs marché : `rafraichirAgregatsDepot` (L322–347,
`marche->recalculer()` + bilan) **uniquement** si
`withDepotHistorique`. Le clic fiche ne recalcule pas.

Tables touchées au clic NV1 :

| Table | Écriture |
|-------|----------|
| `workflow_validations` | nv1 → `valide` ; création nv2 **ou** mise_paiement `en_attente` |
| `factures.statut` | `en_validation_nv2` ou `a_comptabiliser` |
| `depot_factures.statut` | même valeur (sync) |
| `facture_historique` | `valide_nv1` **seulement** si masse / `/validations` |
| `tasks` | clôture NV1 ; upsert NV2 si ENVOL |
| `marche_historique` | log observer si `marche_id` |
| `alertes` | non (garde éteinte) |

### 1.2 NV1 automatique à l’envoi (déposant = valideur)

**Pas un observer, pas un statut-hook.** Uniquement dans
`DepotFactureController::valider` (`app/Http/Controllers/DepotFactureController.php`),
à la **création** de la facture.

Garde d’entrée L2128–2132 : si `$depot->facture_id !== null` → 422
« déjà engagée dans le workflow ». Ce chemin **ne peut pas** se
rejouer sur une facture déjà créée.

Déclenchement L2191–2225 :

- `$estMemeValidateur` = l’utilisateur qui envoie **est**
  l’attributaire (`utilisateurEstAttributaire` L3045–3060, résolution
  `FactureValidateurNv1AssignmentService::resolveUser`).
- Boîte 3 issues #1189 (`envoi_intention`) : `valider_nv1` /
  `envoyer_sans_valider` / `bloquer`. Si pas attributaire ou
  `deja_reglee` → intention ignorée (L2202–2204).
- `$forceSansAutoNv1` si `envoyer_sans_valider` ou `bloquer`
  (L2219–2222) → **pas** d’auto-NV1.
- `$estValidateurDesigne = ! $dejaReglee && $estMemeValidateur && ! $forceSansAutoNv1`.
- Commentaire WV : `validation_deliberee_deposant` si choix explicite
  `valider_nv1`, sinon `validation_automatique_deposant` (L2223–2225,
  constantes `WorkflowValidationStepPresenter` L15–18).

Dans la transaction (L2309–2374) :

- Statut facture d’emblée `a_comptabiliser` (skip NV2) ou
  `en_validation_nv2` (ENVOL) — **jamais** `en_validation_nv1`.
- `creerWorkflowsApresDepotAutoNv1` (`FactureWorkflowApresNv1Service`
  L44–80) : crée **directement** nv1/`valide` (avec `validateur_id` =
  déposant, `date_validation` = now, commentaire marqueur) **plus**
  nv2/`en_attente` **ou** mise_paiement/`en_attente`. Il n’existe
  jamais d’étape nv1/`en_attente` dans ce cas.

Après transaction (L2511–2559) :

- Historique dépôt : `valide_nv1_a_l_envoi` si délibéré ; toujours
  `envoye_nv2` (ENVOL) ou `envoye_comptabilisation` (skip NV2).
  Legacy auto ENVOL : seulement `envoye_nv2` avec commentaire
  `skip NV1 - utilisateur est l'attributaire` (L2537–2538) — **pas**
  d’événement `valide_nv1`.
- `mettreAJourCalculsMarche` L2552 (recalcul marché + bilan) — **oui**
  à l’envoi, contrairement au clic fiche.
- Notifications : `notifierValidateurNv2` si ENVOL ; `notifierComptable`
  + alerte (no-op) si skip NV2. Pas de `notifierValidateurNv1` (l’étape
  est déjà close).

Autres auto-NV1, **hors** « je me suis laissé moi-même » :

- `DepotFactureValiderDejaValideService` (`#233`) : option « déjà
  validé, à comptabiliser ». Marqueur
  `validation_deja_validee_depot` (L389–394). Appelle le même
  `apresNv1Valide`.
- `deja_reglee` : **aucune** ligne WV créée à l’envoi (L2356 :
  `if (! $dejaReglee)`). #1489 peut clôturer en `sans_objet` si
  coché plus tard.

---

## 2. CE QU’IL FAUDRAIT DÉFAIRE

Décision posée : retour en **NV1 à valider**, pas en saisie. Donc on
défait l’**acte** NV1 et ses effets aval **non encore consommés**
(WV nv2/`en_attente` ou mise_paiement/`en_attente`, statut, tâche
NV2), pas l’engagement de la facture (`facture_id` reste).

### 2.1 Ligne `workflow_validations` NV1 — ne pas laisser `valide`

`WorkflowValidationStepPresenter::workflowPourEtape` (L26–44) prend
**la première** ligne `statut = valide` du niveau. Si on crée une
nouvelle nv1/`en_attente` **sans** retirer le `valide`, la frise
reste validée.

`DepotFacture::sqlWorkflowNv1Validee` (L439–447) et le bilan
« Facturé » (`sqlConditionCompteDansFactureApresNv1Valide` L454–488)
comptent **toute** ligne nv1/`valide`. Idem : il ne doit plus rester
de nv1/`valide`.

Enum actuel (`database/migrations/2026_09_08_184900_add_sans_objet_to_workflow_validations_statut_enum.php`
L23–29) : `en_attente`, `valide`, `rejete`, `relance`, `sans_objet`.
**Pas** de `annule`.

Options (ne pas trancher) :

| Option | Effet | Trace de l’ancienne validation | Migration |
|--------|-------|--------------------------------|-----------|
| **A — update in-place** : même ligne nv1 → `en_attente`, `date_validation = null`, garder `validateur_id` (désigné), vider le commentaire technique | Frise + SQL redeviennent « à valider ». Motif **hors** WV (historique). | Perdue dans WV ; conservée si `facture_historique` | Non |
| **B — `sans_objet` sur l’ancienne + nouvelle nv1/`en_attente`** | Distingue « plus d’objet » (#1489 déjà réglée) d’une annulation. Risque de confusion sémantique. | Ancienne ligne conservée | Non |
| **C — nouvel enum `annule` + nouvelle nv1/`en_attente`** | Lisibilité maximale | Ancienne ligne conservée | **Oui** (hors PHASE 2 si on veut rester sans migration) |
| **D — DELETE de la ligne nv1/`valide` + recreate `en_attente`** | Simple. Perd l’id WV (tâches taskable = WV, #1457). | Uniquement historique | Non |

Recommandation technique **sans décision métier** : **A** ou **B**
sans migration. **C** seulement si on refuse de réutiliser
`sans_objet`. **D** à éviter (casse le taskable).

Ne **pas** poser le motif obligatoire dans `workflow_validations.commentaire`
de la ligne remise en `en_attente` : le prochain clic Valider
l’écrase (`FactureValidationService` L119).

### 2.2 Statut dépôt / facture — la valeur cible existe

Cible métier : NV1 à valider.

Enums MySQL réels (dernières migrations) :

- `factures.statut` :
  `recue`, `en_validation_nv1`, `en_validation_nv2`,
  `a_comptabiliser`, `comptabilisee`, `en_paiement`, `payee`,
  `rejetee`
  (`database/migrations/2026_05_29_140000_add_comptabilisee_to_factures_statut_enum.php` L21–30).
  Constantes `Facture::statutsAutorisesColonne` L36–47 : **alignées**.
- `depot_factures.statut` :
  `depose`, `attribue`, `en_analyse`, `analyse`, `en_cours_saisie`,
  `en_validation_nv1`, `en_validation_nv2`, `a_comptabiliser`,
  `comptabilisee`, `valide`, `paye`, `rejete`, `archive`
  (`database/migrations/2026_05_29_100000_add_comptabilisee_to_depot_factures_statut_enum.php` L21–35).
  Constantes `DepotFacture::statutsAutorisesColonne` L78–94 :
  **alignées**.

`FactureStatutService::RAW_GROUPS` L16–27 mappe aussi
`en_attente_nv1` et `nv1_en_cours` vers le badge `nv1_en_cours`.
Ces alias **ne sont pas** dans l’ENUM MySQL. Le code écrit
`en_validation_nv1` (`DepotFacture::STATUT_EN_VALIDATION_NV1` L35,
`Facture::STATUT_EN_VALIDATION_NV1` L17). **C’est la bonne valeur
cible** ; elle existe.

Sync : `FactureWorkflowApresNv1Service::syncDepotStatut` L142–160 et
`FactureController::syncDepotStatut` L1611–1629 — poser la même
chaîne sur les deux tables.

### 2.3 WV aval à fermer (sans toucher une NV2 **validée**)

Si NV2 `valide` → **fermeture** de l’action (condition d’ouverture).
Si NV2 `en_attente` (ENVOL post-NV1) : ce n’est pas « annuler NV2 »,
c’est retirer l’ouverture créée par NV1. Il **faut** la retirer,
sinon `validatePendingStep` validerait la NV2 au prochain clic
(première `en_attente` par `id` — si nv1 revient en_attente **avant**
nv2 en id, le clic reprendrait NV1 ; si on oublie de remettre nv1
en_attente et qu’on laisse nv2, on valide NV2).

Même logique pour `mise_paiement` / `en_attente` (FG / hors ENVOL /
skip NV2). Options : `sans_objet` (déjà dans l’enum, sémantique
#1489 « plus d’objet ») ou DELETE (déjà fait par le super-admin
`FactureWorkflowRealignementService::supprimerNiveauxPostCompta`
L281–287 — **ne pas réutiliser** ce service tel quel : il est
conçu pour un forçage d’étape, vide `validateur_id`, pose
`force_superadmin`).

### 2.4 Retenues, marché, libération RG, miroirs

- **Miroir `retenues_garantie`** : écrit à la comptabilisation
  (`RetenueGarantieService::upsertDepuisComptabilisation` L34+), pas
  à NV1. Rien à retirer tant que hors compta.
- **Libération RG #1535** : `LiberationRgFactureService` L155–178 et
  `FactureStatutService::etatRetenue` L149–161. `etatRetenue !== null`
  dès `estAuDelaDeNv1Inclus`, et `normalizedStatutsAuDelaDeNv1Inclus`
  (L92–102) **inclut `nv1_en_cours`**. Donc une facture **en attente
  de NV1** (après envoi) est déjà dans le plafond et les états
  « en cours ». Le clic Valider NV1 ne change pas cet ensemble
  (on passe de `nv1_en_cours` à `nv2_en_cours` ou `a_comptabiliser`,
  toujours NV1+). **Annuler NV1 vers `en_validation_nv1` ne retire
  pas la libération du plafond.** La retirer exigerait un retour
  brouillon / saisie — **hors périmètre**. À documenter en PHASE 2
  dans l’UI (pas de surprise métier).
- **Retenues RG/CP/OPC/CIE** : mêmes frontières `etatRetenue`.
  Inchangées par l’annulation.
- **`marche->recalculer()` / bilan** : appelé à l’envoi
  (`mettreAJourCalculsMarche` L3018–3042) et à la masse, **pas** au
  clic fiche. Recalcul PHASE 2 : utile pour cohérence observer /
  historique marché, pas pour « défaire un agrégat NV1-only ».
- **Pénalités / avoirs** : synchronisés à l’envoi
  (`synchroniserPenalitesImputees`, `ApplicationAvoirDepotService`),
  pas au clic NV1. Les laisser.

### 2.5 Notifications et tâches

| Effet | Réversible ? |
|-------|----------------|
| E-mail NV1 / NV2 / compta | Jamais envoyé (Log::info « Email désactivé »). Rien à compenser. |
| Alerte « à comptabiliser » | Non générée (`GENERATION_ACTIVE = false`). Si réactivée plus tard : `updateOrCreate` sur `(Facture, SCENARIO_FACTURE_A_COMPTABILISER)` — PHASE 2 devrait `delete` cette alerte au cas où. |
| Tâche NV1 | Close au clic / à l’auto. **Réouvrir** via `upsertAfterNv1Entry` (hooks L18–27, exige `statut === en_attente`). |
| Tâche NV2 | Créée si ENVOL. **Fermer** via `closeAfterNv2Action` (sans valider NV2). Pattern déjà utilisé par #1489 (`FactureDejaRegleeClotureWorkflowService`). |

Pas de Teams à compenser sur ce circuit (e-mails Graph coupés).

### 2.6 Historique dépôt / facture

Le clic fiche **ne pose pas** `valide_nv1` (`withDepotHistorique = false`).
L’auto-envoi pose `envoye_nv2` / `valide_nv1_a_l_envoi` /
`envoye_comptabilisation`. Ces lignes **restent** (journal
append-only). PHASE 2 ajoute une **nouvelle** ligne (voir §5), ne
supprime rien.

`MarcheHistorique` : nouvel événement au changement de statut
(observer). Acceptable.

---

## 3. LES CONDITIONS D’OUVERTURE

Preuve fiable = **tables workflow + statut**, pas le badge normalisé
seul.

### 3.1 NV1 a bien eu lieu

```
EXISTS workflow_validations
  WHERE facture_id = ? AND niveau = 'nv1' AND statut = 'valide'
```

Équivalent code : `DepotFacture::sqlWorkflowNv1Validee` L439–447.

Sans cette ligne, il n’y a rien à annuler (facture encore en NV1
à valider, ou `deja_reglee` sans WV).

### 3.2 NV2 n’a pas eu lieu

```
NOT EXISTS workflow_validations
  WHERE facture_id = ? AND niveau = 'nv2' AND statut = 'valide'
```

Une ligne nv2/`en_attente` **n’interdit pas** l’annulation : c’est
précisément l’état ENVOL post-NV1. Une ligne nv2/`sans_objet` (#1489)
= NV2 non validée → l’annulation NV1 n’a en général plus de sens
(circuit déjà réglée) — voir §3.4.

Ne **pas** se fier à `factures.statut = en_validation_nv2` : après
NV2 validée le statut passe à `a_comptabiliser`
(`apresNv2Valide` L116–124). Le WV `valide` est la preuve.

### 3.3 Pas comptabilisée

`FactureStatutService::estComptabiliseeOuAuDela` L74–85 :
normalisé ∈ `comptabilisee`, `mise_en_paiement`, `en_paiement`,
`payee` (L64–71).

Statuts bruts concernés : `comptabilisee`, `valide` (dépôt, alias
legacy), `en_paiement`, `paye`/`payee`.

Garde de ceinture : `depot_factures.intacct_bill_id` non vide =
écriture Sage déjà partie — **fermer** même si un statut était
dérivé. Hors périmètre d’annuler après compta (consigne).

`a_comptabiliser` **n’est pas** de la comptabilisation. C’est
l’attente comptable.

### 3.4 Hors ENVOL / FG (pas de NV2)

`FacturePasseParValidationNv2::check` est faux → après NV1 :
`a_comptabiliser` + WV `mise_paiement`/`en_attente`, **aucune**
ligne nv2/`valide`. Les conditions 3.2 et 3.3 tiennent : **l’annulation
reste possible jusqu’à la comptabilisation**. Même mécanique pour
les frais généraux (`FactureEstFraisGeneraux`).

À l’annulation : redescendre `factures` + `depot_factures` vers
`en_validation_nv1`, fermer `mise_paiement`/`en_attente` (sans
toucher une compta).

### 3.5 Options avancées

| Option | NV1 marquée ? | Annulation a-t-elle un sens ? |
|--------|---------------|-------------------------------|
| **Déjà validé, à comptabiliser** (`deja_valide`) | Oui, marqueur `validation_deja_validee_depot` (`DepotFactureValiderDejaValideService` L393). Circuit ensuite NV2 ou `a_comptabiliser`. | Faible : NV1 est une formalité comptable. Un retour en NV1 à valider contredit le flag `deja_valide` encore vrai. Risque : un second appel `validerDejaValide` (`autoValiderNv1EnAttente` L316) re-validerait si nv1 redevient `en_attente`. |
| **Déjà réglée** (`deja_reglee`) | Souvent **aucune** WV nv1 à l’envoi (L2356). Sinon clôture `sans_objet` (#1489). | **Non** : il n’y a pas d’acte NV1 à retirer ; le circuit a sauté les validations. |
| **Saisie historique** | Statut `paye` direct après NV2 ou skip (`apresNv2Valide` L109–113). | Hors périmètre (déjà payée / hors pipeline). |

Proposition PHASE 2 (à confirmer, pas tranchée ici) : **exclure**
`deja_valide`, `deja_reglee`, `saisie_historique`.

### 3.6 Super-admin / forçage

`FactureWorkflowRealignementService` peut poser nv1/`valide` avec
commentaire `force_superadmin`. Si NV2 n’est pas `valide` et pas
compta, les conditions brutes s’appliquent. PHASE 2 : soit les
inclure (même règle), soit les exclure au commentaire. Ne pas
appeler le service de réelignement pour l’annulation métier
(il DELETE des lignes et pose `force_superadmin`).

---

## 4. DROITS

Périmètre décidé : **valideur NV1 de la facture**, `GS_COMPTABILITE`,
`GS_DIRECTION_GENERALE`.

### 4.1 Lecture brute — ne pas utiliser Gate

`User::m365GroupNames()` L119–122 : tableau `m365_groups` **sans**
promotion admin.

`User::hasM365Group` L134–141 et `hasAnyM365Group` L146–153 :
si `isAdminERP()` (`GS_ADMIN_ERP` L103–114) → **true** pour tout
groupe.

`Gate::before` (`AppServiceProvider.php` L69–75) : `isAdminERP()` →
`true` pour **toute** ability. Tout `authorize()` / policy est
faussé pour le compte Robin.

Pattern à copier : `FactureController::userCanEditPaiementFondsPropres`
L184–198 (`in_array(..., $user->m365GroupNames())`) et
`DepotFactureController::deverrouiller` L3112–3124
(`FactureVerrou::userCanRetirer` + `abort_unless`).

Serveur :

```
abort_unless($user instanceof User, 403);
$groups = $user->m365GroupNames();
$viaGroupe = in_array('GS_COMPTABILITE', $groups, true)
    || in_array('GS_DIRECTION_GENERALE', $groups, true);
$viaActe = /* users.id === WV nv1/valide.validateur_id */;
abort_unless($viaGroupe || $viaActe, 403);
```

Ne **pas** ajouter `GS_ADMIN_ERP` dans cette liste (hors décision).
Un admin ERP n’annule que s’il est le valideur NV1 **ou** membre
réel de compta / DG.

### 4.2 Qui est « le valideur NV1 » — ce qui fait foi

Trois informations distinctes :

| Source | Ce que c’est | Fiable pour l’acte ? |
|--------|----------------|----------------------|
| `depot_factures.validateur_id` | **N’existe pas** (aucune migration, #1457 L241, #1508, #1511 L272). Accessor `getValidateurEmailAttribute` L797–806 lit le JSON. | — |
| `ocr_raw.saisie.validateur_id` (+ email) | **Désigné** (qui *devrait* valider). azure_id ou `users.id` string ; double-guillemets historiques #1459. Résolution : `FactureValidateurNv1AssignmentService::resolveValidateurDepuisSaisie` L122–143 / `DepotFactureValidateurEffectif::idEffectif` L75–90 (repli réglages fournisseur **puis déposant**). | Non : ce n’est pas qui a cliqué. Le repli déposant ferait d’un dépôt sans désigné un faux positif. |
| `workflow_validations.validateur_id` où `niveau = nv1` et `statut = valide` | **Acteur** de l’acte. FK `users.id` (modèle L51–54). Posé au clic (L117–118) et à l’auto-envoi (L53). | **Oui** — c’est « le valideur NV1 lui-même ». |

Cas DG / responsable programme qui valide à la place du désigné :
l’acte porte **leur** `users.id`, pas le JSON saisie. C’est eux qui
peuvent annuler au titre de « valideur NV1 », plus compta / DG via
groupe.

Auto-NV1 : `validateur_id` WV = déposant (`users.id`) — cohérent.

Après update in-place vers `en_attente`, ce `validateur_id` peut
rester (c’est le désigné à réafficher / réassigner la tâche).

---

## 5. TRAÇABILITÉ

Motif obligatoire → analogie rejet / blocage : champ `commentaire`
(rejet L1498–1500 + `logHistorique('rejete')` L1516 ; blocage
`bloque_motif` sur WV).

Où stocker le motif d’annulation :

| Support | Avantage | Inconvénient |
|---------|----------|----------------|
| `facture_historique` (action dédiée, ex. `annule_validation_nv1`, `commentaire` = motif, `user_id` = acteur) | Qui / quand / pourquoi ; déjà affiché sur la fiche (`Show.vue` L1973–1990, `event.commentaire`). Append-only. | Sans entrée dans `FactureHistoriquePresenter::ACTION_LABELS` (L15–57) le libellé tombe en `str_replace('_', ' ', $action)` (L226) — lisible mais laid. |
| `workflow_validations.commentaire` de la ligne nv1 | Visible dans `CommentairesValidationList` **si** statut `valide` (presenter L54–70). | Écrasé si on remet `en_attente` puis re-Valider. Les marqueurs techniques auto sont **filtrés** (L58–60). |
| Colonne dédiée | Sur-mesure | Migration. Inutile si historique suffit. |

`WorkflowValidationCommentairesPresenter::depuisHistoriqueDepot`
(L112–136) ne relit que `bloque_nv1`, `debloque_nv1`,
`rejet_comptabilisation`. Une action nouvelle **n’apparaît pas**
dans la liste commentaires sous la frise, seulement dans le
dialogue Historique — sauf extension PHASE 2 (ajouter l’action à
cette whitelist **et** un `actionLabel` dans
`CommentairesValidationList.vue` L44–58, aujourd’hui
valide/rejeté/bloqué/débloqué).

Proposition (non tranchée, recommandation technique) : **historique
obligatoire** (qui, quand, pourquoi, champs avant/après statuts +
id WV) + libellé presenter + optionnellement la whitelist
commentaires pour le voir sans ouvrir l’historique. Pas de colonne
SQL nouvelle.

---

## 6. OÙ PLACER L’ACTION

Barre d’actions fiche : `resources/js/Pages/Factures/Show.vue`
L1733–1850, `flex flex-wrap gap-2` sous le décompte.

Aujourd’hui, de gauche à droite selon conditions :

1. Réattribuer (NV1 en attente, hors barre validation L1734–1742)
2. Valider / Réattribuer / Bloquer|Débloquer / Rejeter — **seulement**
   si `showHeaderValidationActions` (L617–623) : `display_statut` ∈
   `en_validation_nv1|en_validation_nv2|nv1_en_cours|nv2_en_cours`
   **et** étape courante frise `nv1` ou `nv2` **et** au moins un
   droit validate/reject/block
3. Comptabiliser — `a_comptabiliser` (L630, L1796–1803)
4. Annuler la comptabilisation — `comptabilisee` (L631–633, L1804–1812)
5. Actualiser Sage / Virement exécuté
6. Modifier / Supprimer (L1832–1851)

L’annulation NV1 **n’est pas** dans la fenêtre du bouton Valider NV1
(cette fenêtre se ferme dès que NV1 est `valide`). Elle vit **après**
NV1, **avant** compta :

- **ENVOL** : facture en NV2 à valider → la barre 2 est visible
  (Valider = NV2). Placer « Annuler la validation » **dans cette
  même barre**, à droite de Rejeter (ou à gauche de Valider NV2),
  sous `can_annuler_validation_nv1` **indépendant** de
  `permissions.can_validate` (le valideur NV1 n’est souvent pas le
  valideur NV2).
- **Hors ENVOL / FG** : `display_statut = a_comptabiliser` → barre 2
  **masquée**, bouton Comptabiliser visible. Placer l’annulation
  **à côté de Comptabiliser** (avant, outlined danger/undo, distinct
  de « Annuler la comptabilisation » qui n’apparaît que
  `comptabilisee`).

Ne **pas** afficher pendant NV1 `en_attente` (rien à annuler), ni
après NV2 `valide`, ni après compta, ni sur `deja_reglee` /
historique si exclus.

Condition d’affichage = payload serveur `can_annuler_validation_nv1`
calculé avec les gardes §3 + droits §4 (jamais le front seul).

Dialogue : motif obligatoire (comme Rejeter L2156–2163 / Bloquer
L2069–2087). Pas de design à figer ici.

Pas d’action liste globale / masse dans ce diagnostic (hors prompt).

---

## 7. VOLUME — requêtes SQL (à coller en prod)

Cursor n’a pas MySQL. Aucun chiffre inventé.
`only_full_group_by` : colonnes du SELECT dans le GROUP BY.
Pas de `depot_factures.validateur_id`. Pas de `programmes.nom`.

Marqueurs commentaire WV (`WorkflowValidationStepPresenter` L15–22) :

- `validation_automatique_deposant`
- `validation_deliberee_deposant`
- `validation_deja_validee_depot`
- `validation_compta_deja_reglee`
- `force_superadmin`

### 7.1 Éligibles bruts (NV1 valide, NV2 non valide, pas compta / pas payée / pas rejet)

```sql
SELECT
  f.statut AS facture_statut,
  df.statut AS depot_statut,
  df.deja_valide,
  df.deja_reglee,
  df.saisie_historique,
  COUNT(*) AS n
FROM factures f
INNER JOIN workflow_validations wv_nv1
  ON wv_nv1.facture_id = f.id
 AND wv_nv1.niveau = 'nv1'
 AND wv_nv1.statut = 'valide'
LEFT JOIN workflow_validations wv_nv2_valide
  ON wv_nv2_valide.facture_id = f.id
 AND wv_nv2_valide.niveau = 'nv2'
 AND wv_nv2_valide.statut = 'valide'
LEFT JOIN depot_factures df
  ON df.facture_id = f.id
 AND df.deleted_at IS NULL
WHERE f.deleted_at IS NULL
  AND wv_nv2_valide.id IS NULL
  AND f.statut NOT IN ('comptabilisee', 'en_paiement', 'payee', 'rejetee')
  AND (df.id IS NULL OR df.statut NOT IN ('comptabilisee', 'paye', 'rejete', 'archive'))
  AND (df.intacct_bill_id IS NULL OR TRIM(df.intacct_bill_id) = '')
GROUP BY
  f.statut,
  df.statut,
  df.deja_valide,
  df.deja_reglee,
  df.saisie_historique
ORDER BY n DESC;
```

### 7.2 Dont NV1 automatique / délibérée à l’envoi / clic / déjà-validé

```sql
SELECT
  CASE
    WHEN wv_nv1.commentaire = 'validation_automatique_deposant' THEN 'auto_deposant'
    WHEN wv_nv1.commentaire = 'validation_deliberee_deposant' THEN 'deliberee_a_l_envoi'
    WHEN wv_nv1.commentaire = 'validation_deja_validee_depot' THEN 'deja_valide'
    WHEN wv_nv1.commentaire = 'validation_compta_deja_reglee' THEN 'compta_deja_reglee'
    WHEN wv_nv1.commentaire = 'force_superadmin' THEN 'force_superadmin'
    WHEN wv_nv1.commentaire IS NULL OR TRIM(wv_nv1.commentaire) = '' THEN 'clic_sans_commentaire'
    ELSE 'clic_avec_commentaire_libre'
  END AS nature_nv1,
  COUNT(*) AS n
FROM factures f
INNER JOIN workflow_validations wv_nv1
  ON wv_nv1.facture_id = f.id
 AND wv_nv1.niveau = 'nv1'
 AND wv_nv1.statut = 'valide'
LEFT JOIN workflow_validations wv_nv2_valide
  ON wv_nv2_valide.facture_id = f.id
 AND wv_nv2_valide.niveau = 'nv2'
 AND wv_nv2_valide.statut = 'valide'
LEFT JOIN depot_factures df
  ON df.facture_id = f.id
 AND df.deleted_at IS NULL
WHERE f.deleted_at IS NULL
  AND wv_nv2_valide.id IS NULL
  AND f.statut NOT IN ('comptabilisee', 'en_paiement', 'payee', 'rejetee')
  AND (df.id IS NULL OR df.statut NOT IN ('comptabilisee', 'paye', 'rejete', 'archive'))
  AND (df.intacct_bill_id IS NULL OR TRIM(df.intacct_bill_id) = '')
GROUP BY nature_nv1
ORDER BY n DESC;
```

### 7.3 Split ENVOL (NV2 ouverte) vs skip NV2 (`a_comptabiliser`)

```sql
SELECT
  CASE
    WHEN EXISTS (
      SELECT 1 FROM workflow_validations wv2
      WHERE wv2.facture_id = f.id
        AND wv2.niveau = 'nv2'
        AND wv2.statut = 'en_attente'
    ) THEN 'envol_nv2_en_attente'
    WHEN f.statut = 'a_comptabiliser' THEN 'skip_nv2_a_comptabiliser'
    WHEN f.statut = 'en_validation_nv2' THEN 'nv2_statut_sans_ligne_attente'
    ELSE CONCAT('autre:', f.statut)
  END AS circuit,
  COUNT(*) AS n
FROM factures f
INNER JOIN workflow_validations wv_nv1
  ON wv_nv1.facture_id = f.id
 AND wv_nv1.niveau = 'nv1'
 AND wv_nv1.statut = 'valide'
LEFT JOIN workflow_validations wv_nv2_valide
  ON wv_nv2_valide.facture_id = f.id
 AND wv_nv2_valide.niveau = 'nv2'
 AND wv_nv2_valide.statut = 'valide'
LEFT JOIN depot_factures df
  ON df.facture_id = f.id
 AND df.deleted_at IS NULL
WHERE f.deleted_at IS NULL
  AND wv_nv2_valide.id IS NULL
  AND f.statut NOT IN ('comptabilisee', 'en_paiement', 'payee', 'rejetee')
  AND (df.id IS NULL OR df.statut NOT IN ('comptabilisee', 'paye', 'rejete', 'archive'))
  AND (df.intacct_bill_id IS NULL OR TRIM(df.intacct_bill_id) = '')
GROUP BY circuit
ORDER BY n DESC;
```

### 7.4 Contrôle : plusieurs lignes nv1/`valide` (frise / SQL ambigus)

```sql
SELECT wv.facture_id, COUNT(*) AS n_nv1_valide
FROM workflow_validations wv
INNER JOIN factures f ON f.id = wv.facture_id AND f.deleted_at IS NULL
WHERE wv.niveau = 'nv1' AND wv.statut = 'valide'
GROUP BY wv.facture_id
HAVING COUNT(*) > 1;
```

---

## POINT DUR — NV1 AUTOMATIQUE AU RETOUR

**Conclusion factuelle : pas de rejeu automatique, donc pas de boucle.**

Preuves :

1. L’auto-NV1 n’existe que dans `DepotFactureController::valider`
   (création facture). Garde L2128–2132 : `facture_id !== null` →
   refus. Un retour en NV1 **garde** `facture_id`.
2. `FactureObserver` ne valide rien.
3. Aucun autre appelant de `creerWorkflowsApresDepotAutoNv1` que
   ce `valider()` (grep).
4. `FactureValidateurNv1AssignmentService::syncFromDepotSaisieIfNv1Pending`
   (L192–226) **assigne** seulement, ne valide pas.
5. `autoValiderNv1EnAttente` (déjà-validé) ne part que de
   `validerDejaValide` (action comptable explicite).
6. Après retour, le déposant-valideur verrait le bouton **Valider**
   de la fiche (clic explicite). La boîte 3 issues #1189 n’existe
   qu’à l’envoi. Ce n’est plus un automatisme.

Options (ne pas trancher) :

- **A — Autoriser** l’annulation y compris auto-NV1. Pas de flag.
  L’utilisateur re-valide par clic s’il confirme. Couvre le besoin
  « je me suis laissé moi-même par erreur ».
- **B — Interdire** si `commentaire` ∈
  `{validation_automatique_deposant, validation_deliberee_deposant}`.
  L’annulation ne sert alors que le clic NV1 distinct de l’envoi.
  Plus simple politiquement, plus étroit métier.
- **C — Autoriser + défense en profondeur** : flag
  `ocr_raw.saisie.nv1_auto_bloquee` (ou équivalent) lu par
  `valider()` au cas où un futur hook réintroduirait l’auto. Coût
  sans bénéfice aujourd’hui.
- **D — Autoriser mais exiger une réattribution** du valideur si
  c’était de l’auto. Lourde, hors besoin.

La boucle redoutée **n’existe pas dans le code actuel**. Un flag
anti-rejeu n’est pas nécessaire pour corriger un automatisme ; c’est
une assurance produit (option C) ou une restriction (option B).

---

## PÉRIMÈTRE PROPOSÉ POUR LA PHASE 2

Inclus :

- Endpoint serveur (fiche) : motif required, `abort_unless` groupes
  bruts + `workflow_validations` nv1/`valide`.`validateur_id`.
- Gardes : NV1 `valide` ; NV2 pas `valide` ; pas
  `estComptabiliseeOuAuDela` ; pas `intacct_bill_id` ; hors
  `deja_reglee` / `saisie_historique` (et, sauf décision contraire,
  hors `deja_valide`).
- Retour `factures.statut` + `depot_factures.statut` =
  `en_validation_nv1` (valeur ENUM réelle).
- WV nv1 : plus `valide` (update in-place `en_attente` **ou**
  `sans_objet` + nouvelle `en_attente` — à trancher).
- WV nv2/`en_attente` ou mise_paiement/`en_attente` : fermer
  (`sans_objet` ou delete ciblé), **sans** toucher une NV2 `valide`.
- Tâches : close NV2 si ouverte ; upsert NV1.
- Historique `annule_validation_nv1` + libellé presenter.
- Bouton fiche aux deux emplacements §6. Pas de masse.

Exclus (consignes + diagnostic) :

- Annuler NV2, annuler après compta, retour saisie.
- Retirer la libération RG du plafond (inchangée en NV1+).
- Migration enum `annule` (sauf si on refuse A/B).
- `hasAnyM365Group` / Gate / ajout spontané `GS_ADMIN_ERP`.
- Réutiliser `FactureWorkflowRealignementService` (forçage admin).

À trancher avant de coder :

1. Auto-NV1 : A, B, C ou D.
2. WV nv1 : update in-place vs `sans_objet` + nouvelle ligne.
3. Inclure ou exclure `deja_valide`.
4. Le motif dans la liste commentaires sous la frise, ou seulement
   l’historique.

---

## SYNTHÈSE

```markdown
SUIVI #1549 — diagnostic annuler validation NV1

Auto-NV1 : PAS de boucle. Déclenché uniquement à l’envoi
(DepotFactureController::valider L2219-2374 /
creerWorkflowsApresDepotAutoNv1). Garde facture_id déjà posé
L2128-2132. Observer facture : log marché seulement. Au retour
en NV1 à valider, un nouveau clic fiche est nécessaire.
Options A autoriser / B interdire l’auto / C flag défense /
D réattribution — non tranchées.

Clic Valider NV1 : FactureValidationService::validatePendingStep
→ WV nv1 valide + apresNv1Valide (nv2 en_attente ENVOL, sinon
a_comptabiliser + mise_paiement). Fiche : pas d’historique
valide_nv1 (withDepotHistorique false). E-mails coupés, alertes
éteintes. Tâche NV2 créée si ENVOL.

À défaire : WV nv1 plus valide (sinon frise + bilan Facturé
restent validés) ; statuts → en_validation_nv1 (ENUM réel des
deux tables) ; fermer nv2/mise_paiement en_attente seulement ;
réouvrir tâche NV1. Libération RG : reste au plafond (NV1+
inclut nv1_en_cours, #1535). Miroir retenues_garantie : écrit
à la compta, intouché.

Ouverture : WV nv1/valide ET pas nv2/valide ET pas
comptabilisee/en_paiement/payee ET pas intacct_bill_id.
Hors ENVOL / FG : oui tant que pas comptabilisé.
deja_reglee / historique : non. deja_valide : discutable.

Droits : m365GroupNames() brut + abort_unless
GS_COMPTABILITE / GS_DIRECTION_GENERALE, ou
WV nv1/valide.validateur_id. Pas ocr_raw.saisie.validateur_id
(désigné ≠ acte). Pas depot_factures.validateur_id (n’existe
pas). Pas hasAnyM365Group / Gate.

Motif : facture_historique (action nouvelle) ; presenter à
étendre. Placement : barre fiche — à côté de Valider NV2
(ENVOL) ou de Comptabiliser (skip NV2). Pas pendant NV1
en_attente, pas avec Annuler la comptabilisation.

PHASE 2 proposée : endpoint + bouton + historique ; pas de
migration ; pas de touche NV2 validée ni compta ni saisie.

SQL volume : §7 de la fiche (éligibles, nature_nv1, circuit
ENVOL vs skip, doublons nv1/valide).
```

## DÉPLOIEMENT-TEST

Aucun code applicatif. Fiche seule.
OVH : `git pull origin main` suffit à la faire figurer dans
`docs/suivi/`. Pas de migrate, pas de journal:sync, pas de build.

## LEÇON

L’auto-NV1 est un raccourci **d’envoi**, pas un invariant d’état.
`facture_id` déjà posé bloque le raccourci. Le risque réel au
retour n’est pas une boucle, c’est de laisser une ligne
nv1/`valide` (frise + SQL « Facturé ») ou une nv2/`en_attente`
(le prochain Valider consommerait NV2). `en_validation_nv1` est
dans les deux ENUM ; `nv1_en_cours` n’est qu’un badge. La
libération RG « dès NV1 » est dès l’envoi (`nv1_en_cours` ∈
NV1+), pas dès le clic Valider : l’annulation vers NV1 à valider
ne la retire pas. Valideur acte = WV, pas le JSON saisie.
`hasAnyM365Group` est inutilisable pour ce droit (Gate::before
admin).
