---
suivi: 1491
date: 2026-09-08
sujet: Diagnostic décalage 2 h facture_historique.created_at
chantier: factures
type: diagnostic
statut: poussé
hash: b5fecb7c
fichiers:
  - docs/suivi/SUIVI_1491_diag_decalage_horaire_historique.md
---

# SUIVI #1491 — PHASE 1 DIAGNOSTIC — décalage horaire facture_historique

## PROMPT ENVOYÉ

PHASE 1 diagnostic et cadrage. Ne pas coder la migration sans validation
de Robin. Cause #1488 anomalie 3 : `facture_historique.created_at` via
DEFAULT CURRENT_TIMESTAMP (horloge session MySQL) vs Eloquent `now()`
UTC sur `depot_factures` / `workflow_validations`. Mesures SQL à fournir
(Cursor sans MySQL). Options sans trancher. Ne pas toucher
DepotFactureController / FactureFriseEtapePresenter / Show.vue (#1489).

## BASE GIT

Prompt : HEAD prod attendu `0b0fa1bf` (fiche #1488), #1489 annoncé en
cours. Réel : `9683c08d` chore hash fiche #1489 — origin/main à jour,
#1489 déjà poussé. Pas de rebase. Aucun fichier PHP / Vue / config /
migration modifié.

#1489 a touché `DepotFactureController` (chemin « déjà réglée ») ; il n’a
**pas** modifié `FactureFriseEtapePresenter` ni `Show.vue`. Ce lot ne
touche aucun des trois.

MySQL agent : 127.0.0.1:3306 refused. Aucun chiffre inventé.
Aucun PHP modifié.

## FICHES LUES

- **#1488** section Anomalie 3 (LEÇON + L225–258) — timezone app UTC ;
  mysql sans timezone de session ; `facture_historique.created_at`
  TIMESTAMP `useCurrent()` ; modèle `timestamps = false` ;
  `Historisable::logHistorique` n’envoie pas `created_at`. Écart 2 h
  établi code + horodatages Robin (dépôt 1735 / facture 1370). Impact :
  ne pas rediscover la cause ; mesurer type de colonne, ampleur, DST,
  autres DEFAULT CURRENT_TIMESTAMP ; lister les chemins d’écriture
  réellement utilisés.

## VERDICT (en tête)

**Cause code : établie** (reprise #1488, confirmée à la relecture).
**Session MySQL = Europe/Paris : non mesuré** (hypothèse, à confirmer
par `@@session.time_zone` + `TIMEDIFF(NOW(), UTC_TIMESTAMP())`).
**Type TIMESTAMP vs DATETIME : non mesuré** (SHOW COLUMNS) — ça change
le rattrapage.
**Aucune option tranchée.** Option 2 (timezone de connexion) n’est PAS
la seule correction complète ; ce n’est pas un lot mineur.

---

## Étape 1 — Requêtes pour Robin (aucun chiffre ici)

Coller telles quelles. Session = celle de l’ERP (php-fpm), pas un client
graphique avec une autre timezone si possible.

### 1. Timezone session vs UTC

```
SELECT
  @@global.time_zone AS global_tz,
  @@session.time_zone AS session_tz,
  NOW() AS now_session,
  UTC_TIMESTAMP() AS now_utc,
  TIMEDIFF(NOW(), UTC_TIMESTAMP()) AS offset_session_moins_utc;
```

Lecture : offset `02:00:00` en septembre = CEST, confirme Paris.
`SYSTEM` + offset 2 h = l’hypothèse #1488. Autre valeur = infirmer.

### 2. TYPE exact des colonnes (TIMESTAMP vs DATETIME)

```
SHOW COLUMNS FROM facture_historique LIKE 'created_at';
SHOW COLUMNS FROM depot_factures LIKE 'created_at';
SHOW COLUMNS FROM depot_factures LIKE 'updated_at';
SHOW COLUMNS FROM workflow_validations LIKE 'created_at';
SHOW COLUMNS FROM workflow_validations LIKE 'updated_at';
SHOW COLUMNS FROM workflow_validations LIKE 'date_assignation';
SHOW COLUMNS FROM workflow_validations LIKE 'date_validation';
SHOW COLUMNS FROM factures LIKE 'created_at';
SHOW COLUMNS FROM factures LIKE 'updated_at';
```

`Type` : `timestamp` = conversion session à l’écriture/lecture.
`datetime` = littéral, pas de conversion.

`Default` / `Extra` : `CURRENT_TIMESTAMP` = le défaut incriminé.

### 3. Ampleur et constance de l’écart (DST)

Plage et volume (pas de chiffre ici) :

```
SELECT
  COUNT(*) AS n_lignes,
  MIN(created_at) AS premier,
  MAX(created_at) AS dernier
FROM facture_historique;
```

Événement 1735 / 1370 (fait Robin : dépôt 10:32:04 vs histo 12:32:04) :

```
SELECT
  df.id AS depot_id,
  df.updated_at AS depot_updated_at,
  fh.id AS histo_id,
  fh.action,
  fh.created_at AS histo_created_at,
  TIMESTAMPDIFF(SECOND, df.updated_at, fh.created_at) AS delta_secondes
FROM depot_factures df
INNER JOIN facture_historique fh ON fh.depot_facture_id = df.id
WHERE df.id = 1735
ORDER BY fh.id;
```

Distribution par mois :

```
SELECT
  DATE_FORMAT(created_at, '%Y-%m') AS mois,
  COUNT(*) AS n
FROM facture_historique
GROUP BY DATE_FORMAT(created_at, '%Y-%m')
ORDER BY mois;
```

Périodes civiles France 2026 (si la valeur affichée est naïve Paris) :

```
SELECT
  CASE
    WHEN created_at < '2026-03-29 02:00:00' THEN 'CET_avant_29_mars'
    WHEN created_at < '2026-10-25 03:00:00' THEN 'CEST_29_mars_a_25_oct'
    ELSE 'CET_apres_25_oct'
  END AS periode,
  COUNT(*) AS n,
  MIN(created_at) AS premier,
  MAX(created_at) AS dernier
FROM facture_historique
GROUP BY periode
ORDER BY premier;
```

Un `-2 h` uniforme est faux dès qu’une ligne tombe en heure d’hiver
(CET +1). Au 08/09/2026, si `premier` ≥ 29/03/2026 et `dernier` < 25/10/2026,
toutes les lignes existantes sont encore en CEST — ça n’autorise pas un
script « −2 h pour toujours ».

Lignes déjà écrites avec PHP `now()` (pas le défaut MySQL) — NE PAS les
rattraper comme les autres. Actions concernées (code, pas une mesure) :
`commentaire_ajoute`, `commentaire_modifie`, `commentaire_supprime`,
`sync_statut_paiement_sage`, `virement_execute`, et le `supprime_logique`
orphelin. Recoupement :

```
SELECT action, COUNT(*) AS n
FROM facture_historique
GROUP BY action
ORDER BY n DESC;
```

### 4. Toutes les colonnes DEFAULT CURRENT_TIMESTAMP

```
SELECT
  table_name,
  column_name,
  data_type,
  column_type,
  column_default,
  extra,
  is_nullable
FROM information_schema.columns
WHERE table_schema = DATABASE()
  AND (
    UPPER(CAST(column_default AS CHAR)) LIKE '%CURRENT_TIMESTAMP%'
    OR extra LIKE '%on update CURRENT_TIMESTAMP%'
  )
ORDER BY table_name, ordinal_position;
```

Source migrations `useCurrent()` (inventaire code, pas la prod) :
`facture_historique.created_at`, `marche_historique.created_at`,
`lot_commercial_historique.created_at`, `reservation_historique.created_at`,
`bilan_etude_fi_historique.created_at`, `ged_actions_log.created_at`,
`impersonation_logs.created_at`, `facture_commentaire_mentions.created_at`,
`facture_reaffectations.created_at`, `fournisseur_creations_commercial.created_at`,
`bug_report_commentaires.created_at`, `assistant_messages.created_at`,
`assistant_suggestions.created_at`, `taches_digest_envois.created_at`,
`taches_digest_runs.created_at`, `foncier_alertes` (created_at),
`location_*` (created_at), `failed_jobs.failed_at`,
`contact` access logs `accessed_at`.
La requête information_schema tranche ce qui est réellement en base.

---

## Étape 2 — Lecture du code

### 5. Chemins d’écriture de `facture_historique`

`Historisable` n’est PAS le seul chemin.

| Fichier | Ligne | Envoie `created_at` ? | Horloge |
|---|---|---|---|
| `app/Traits/Historisable.php` `logHistorique` | L27–37 | **NON** | DEFAULT session MySQL |
| Callers `logHistorique` (DepotFacture, services, commandes, `FactureDejaRegleeClotureWorkflowService` L100) | — | via le trait | idem |
| `FactureCommentaireService` | L158–168 | **OUI** `now()` | PHP UTC |
| `FacturePaiementStatutSyncService` | L368–377 | **OUI** `now()` | PHP UTC |
| `FactureVirementExecuteService` | L107–119 | **OUI** `now()` | PHP UTC |
| `FactureSuppressionService` (facture orpheline) | L342–347 | **OUI** `now()` | PHP UTC |
| Tests (plusieurs) | — | parfois `create([...])` | hors prod |

Pas de `DB::table('facture_historique')->insert` applicatif trouvé.
`PurgeFacturesTest` truncate seulement.

**Conséquence :** la table mélange deux horloges. Un rattrapage unique
sur toutes les lignes serait faux.

### 6. Autres modèles `timestamps = false` + `useCurrent()`

Le piège = les trois à la fois : pas de timestamps Eloquent, pas de
`created_at` dans l’INSERT, colonne `useCurrent()`.

Contre-exemples (même `useCurrent()` en migration, mais PHP écrit `now()`
via boot ou payload) : `MarcheHistorique` (`creating` L16–20),
`ReservationHistorique`, `LotCommercialHistorique`,
`AvisImpositionHistorique`, `GedActionLog::logSafe` L82,
`DemandeJuridiqueHistoriqueService` (tous les `create` ont `now()`),
`DemandeFournisseurHistoriqueService`, `BilanEtudeFiHistoriqueService`,
`ImpersonationService`, `FournisseurPartenaireCommercialService` L118,
`FactureCommentaireMention` create L123, `FactureController` réaffectation
L455.

`FactureHistorique` n’a **pas** de boot `creating`. C’est le cas
confirmé. Les autres colonnes information_schema restent à vérifier
une par une après la requête (4).

### 7. Écrans et calculs qui lisent `facture_historique.created_at`

| Surface | Fichier | Visible ? |
|---|---|---|
| Dialogue historique fiche facture | `FactureController` L800–812 + `FactureHistoriquePresenter` L148 `toIso8601String` + `Show.vue` L1928–1929 `shortDate` = `formatDate` = **jour seul** (`useFormatters.js` L25–31) | Heure **non** affichée. Décalage visible seulement si le jour civil bascule (autour de minuit). Attribut `title` = ISO brut (heure au survol). |
| Panneau historique comptabilisation | `ComptabilisationController::historique` L3068–3082 + `ComptabilisationHistoriquePanel.vue` L61–71 `toLocaleString` **jour + heure** | **VISIBLE** (2 h si le navigateur interprète l’ISO en UTC). Relatif « il y a X h » aussi biaisé. |
| Audit super-admin fiche | `FactureSuperAdminAuditPresenter` L141–142 `date_label` = `d/m/Y H:i` + `Show.vue` L2176 | **VISIBLE** (heure). |
| Commentaires NV bloqué / rejet (frise) | `WorkflowValidationCommentairesPresenter` L156 date ISO + `CommentairesValidationList.vue` `formatDate` jour seul | Heure non affichée ; même réserve minuit. |
| Retenue de garantie date compta | `RetenueGarantieService::resoudreDateRetenueCompta` L411–417 `MIN(created_at)` puis `startOfDay()` | **Latent** sauf frontière de jour. |
| Compte prorata date prélèvement / paiement | `CompteProrataService` L593–618 `MIN`/`MAX(created_at)` + `startOfDay()` | **Latent** sauf frontière de jour. |
| Tableau de bord compta | `ComptaTableauBordService` | **N’utilise pas** `facture_historique` (#1488 : `depot_factures.created_at`). |
| Journal utilisateur (`journal_mises_a_jour`) | autre table, `date_maj` date | Hors sujet. |
| Tri / limite 50 événements fiche | `orderByDesc('created_at')` | Ordre relatif des lignes Historisable entre elles cohérent ; mélange avec lignes `now()` peut inverser deux événements proches. |

`config/app.php` L68 : `'timezone' => 'UTC'` (lu).
`config/database.php` connexion `mysql` L47–65 : **aucune** clé
`timezone` (lu). Non modifié.

---

## Étape 3 — Options (sans trancher)

### Option 1 — PHP écrit `created_at` dans `logHistorique` (`now()`)

Ajouter `'created_at' => now()` dans `Historisable` L27–37 (et/ou un
`creating` comme `MarcheHistorique`). Les autres `FactureHistorique::create`
le font déjà.

- Corrige **l’avenir** des chemins trait.
- Laisse le passé décalé.
- Réversible (retirer la clé).
- N’aligne pas les lectures TIMESTAMP déjà stockées.
- Risque faible. Périmètre petit. Ne touche pas la timezone de connexion.

### Option 2 — `timezone => '+00:00'` sur la connexion mysql

`config/database.php` : Laravel enverrait `SET time_zone = '+00:00'`.

- Aligne la session sur PHP UTC : DEFAULT CURRENT_TIMESTAMP = UTC.
- **Toutes** les colonnes TIMESTAMP de **toute** la base changent de
  valeur affichée à la lecture (conversion session). Y compris
  `depot_factures`, `factures`, `workflow_validations`, jobs, logs, etc.
- Les TIMESTAMP déjà écrits par Eloquent avec une session Paris ont un
  instant UTC stocké **décalé** (PHP a envoyé une chaîne UTC, MySQL l’a
  crue Paris). Passer la session à UTC ferait apparaître ces valeurs
  ~2 h plus tôt (été) / ~1 h (hiver) par rapport à ce que l’UI montre
  aujourd’hui.
- Irréversible sans reverse soigneux + recette sur tous les modules.
- **Clause d’arrêt :** ce n’est pas un lot de correction mineure
  (1277 dépôts, ~4 mois d’historique granulaire — volumes #1488, non
  re-mesurés ici). **Ne pas exécuter dans ce diagnostic.**

Option 2 n’est **pas** la seule correction complète : l’option 1 suffit
à arrêter d’empirer `facture_historique`. Option 2 = chantier d’infra.

### Option 3 — Rattrapage des lignes existantes

- Un `INTERVAL -2 HOUR` uniforme est faux en heure d’hiver (CET +1) et
  faux sur les lignes déjà écrites avec `now()` (groupe « déjà UTC »).
- Si TIMESTAMP : l’instant stocké des lignes DEFAULT est probablement
  le bon UTC ; « corriger » en soustrayant 2 h **casserait** l’instant
  réel et n’alignerait l’affichage que tant que la session reste Paris.
- Si DATETIME : les littéraux sont des heures Paris ; convertir vers UTC
  = −2 h (CEST) ou −1 h (CET) selon la date civile de **chaque** ligne.
- Filtrer par `action` (exclure les 4 chemins déjà `now()`).
- Irréversible sans backup. À n’envisager qu’après SHOW COLUMNS + plage
  DST + dénombrement par action.

### Option 4 — Ne rien faire sur le passé (défendable)

La fiche facture n’affiche **que le jour**. Le décalage 2 h n’y saute
aux yeux que près de minuit. Le panneau compta et l’audit super-admin
montrent l’heure : c’est là que c’est visible.

Défendable si Robin juge le bruit UI acceptable et choisit seulement
l’option 1 pour l’avenir.

### Option 5 — rustine d’affichage (interpréter en Europe/Paris)

Presenter / front : traiter `created_at` comme Paris. Ne corrige pas
les KPI `startOfDay`, ni le mélange de lignes `now()` vs DEFAULT.
Fragile. Non recommandé comme correction de fond ; mentionnée pour
exhaustivité.

---

## Ce qui n’a pas pu être établi

- `@@session.time_zone` et l’offset réel OVH (hypothèse Paris).
- TIMESTAMP vs DATETIME en prod (migrations disent timestamp ; SHOW
  COLUMNS tranche).
- Nombre de lignes, min/max, part CEST vs CET, part déjà en `now()`.
- Instant UTC réellement stocké (TIMESTAMP) vs littéral affiché.
- Liste exhaustive prod des DEFAULT CURRENT_TIMESTAMP (seulement
  migrations + information_schema à faire tourner).

## Hors périmètre respecté

Aucun PHP, aucune migration, aucun script de reprise.
`config/database.php` / `config/app.php` non modifiés.
`DepotFactureController`, `FactureFriseEtapePresenter`, `Show.vue` non
modifiés. Cast `ocr_raw` non touché.

## DÉPLOIEMENT-TEST

Après `git pull` : lire cette fiche. Exécuter les requêtes étape 1.
Coller les résultats avant tout lot de fix.
Pas de `migrate`. Pas de `journal:sync`. Pas de npm/composer/optimize.

## LEÇON

Deux horloges dans la même table dès qu’un modèle coupe `$timestamps`
sans fournir `created_at` alors que la colonne a `useCurrent()`.
`Historisable` n’est pas le seul INSERT : un rattrapage « toutes les
lignes −2 h » casserait les événements déjà horodatés par PHP.
TIMESTAMP + session ≠ PHP TZ = double conversion à la lecture Eloquent
(MySQL convertit vers la session, Carbon relit en UTC, le front reconvertit).
Changer la timezone de connexion n’est pas un correctif local.
