# Vulcan Admin (admin v2) — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
> Spec: `docs/superpowers/specs/2026-07-12-admin-v2-design.md` (lire d'abord — ce plan référence ses règles sans les répéter).

**Goal:** Back-office complet neuf sous `/gestion` (dashboard, commandes + suivi La Poste, produits, ateliers, bandeaux, paramètres, stats anti-bot, notifications), parallèle à l'ancien `/admin`, testé de bout en bout.

**Architecture:** Firewall Symfony dédié (`^/gestion`) sur l'entité `Admin` existante ; nouvelles entités additives (une migration) ; services métiers injectés (tracking par interface + providers La Poste/mock, settings cachés, mailer centralisé, collecteur analytics par beacon) ; UI Twig custom déclinée des tokens DS v2 sous `body.adm` ; cron console idempotent.

**Tech Stack:** Symfony 7.2, Doctrine ORM 3, PHPUnit 9.6 (WebTestCase), Chart.js 4 vendorisé, vanilla JS (pattern existant), Twig.

## Global Constraints

- Contrainte composer intouchée : `symfony/*: 7.2.*`. Nouveau paquet autorisé : `symfony/lock` (si absent — vérifier, il est peut-être transitif).
- **Aucune modification destructive** de l'existant : `/admin`, contrôleurs v1, templates v1, `StatVisitListener`, emails auth existants restent fonctionnels. Modifications client partagées limitées à : include bandeau + beacon dans `base.html.twig`, valeurs `setting()` (footer/contact/homepage/JSON-LD), skip `/gestion` dans `StatVisitListener`.
- Secrets : `LAPOSTE_API_KEY` uniquement dans `.env.local` (jamais commité). `.env` reçoit la ligne vide documentée `LAPOSTE_API_KEY=` + `APP_TRACKING_MOCK=1` par défaut en dev.
- Toute mutation HTTP = POST + CSRF (`csrf_token` Twig ou header `X-CSRF-Token`).
- Tous les écrans admin : FR, scope `body.adm`, tokens copiés de `ds-v2.css` (préfixe conservé `--v2-*`), fonts Fraunces/Inter/IBM Plex Mono, toasts v2 réutilisés (`showNotification`).
- Dev server : `php -d variables_order=EGPCS -S 127.0.0.1:8000 -t public router.php` ; emails test : `MAILER_DSN=null://null`.
- Commits git : après chaque tâche (repo initialisé en T1.1), format `feat(gestion): …` / `test(gestion): …`.
- Convention images produit inchangée : `public/assets/product/{id}-{n}.{ext}`, `-1` = vignette.

---

## Phase 1 — Socle (git, entités, firewall, chrome admin, login, dashboard squelette)

### Task 1.1: Git baseline

**Files:** Create: `.gitignore` (si absent/incomplet — Symfony standard : `/var/`, `/vendor/`, `.env.local*`, `/public/assets/product/` NON ignoré mais photos OK à versionner ? → ignorer `/public/assets/product/*` sauf `.gitkeep` pour éviter binaires ; garder simple), repo git.

- [ ] Vérifier `.gitignore` (doit couvrir `/var/`, `/vendor/`, `.env.local`, `.env.*.local`, `/public/bundles/`) ; compléter si besoin.
- [ ] `git init` + `git add -A` + commit baseline `chore: baseline avant admin v2`.
- [ ] Vérifier : `git log --oneline` → 1 commit ; `git status` propre.

### Task 1.2: Entités + enums + migration

**Files:**
- Create: `src/Entity/Banner.php`, `Setting.php`, `Atelier.php`, `AtelierCreneau.php`, `AtelierReservation.php`, `AdminNotification.php`, `AnalyticsHit.php`, `AnalyticsDaily.php` (+ 8 repositories `src/Repository/*Repository.php`)
- Create: `src/Enum/BannerType.php` (`PROMO='promo'`, `INFO='info'`), `BannerMode.php` (`STATIQUE='statique'`, `DEFILANT='defilant'`), `CreneauStatut.php` (`OUVERT`,`FERME`,`ANNULE`), `ReservationStatut.php` (`CONFIRMEE`,`ATTENTE`,`ANNULEE`), `ReservationSource.php` (`ADMIN`,`SITE`), `NotificationType.php` (`VENTE`,`LIVRAISON`,`SUIVI_ANOMALIE`,`RESERVATION`,`SYSTEME`), `Carrier.php` (`LIBRE='libre'`,`LAPOSTE='laposte'`), `TrackingStatus.php` (`PRIS_EN_CHARGE`,`EN_TRANSIT`,`EN_LIVRAISON`,`DISPONIBLE_RETRAIT`,`LIVRE`,`PROBLEME`,`RETOUR`,`INCONNU` + `label(): string` FR + `isFinal(): bool` → LIVRE|RETOUR)
- Modify: `src/Entity/Commande.php` (ajouter champs spec §2.3 : `carrier` ?string, `trackingUrl` ?string, `trackingEvents` ?array json, `trackingLastStatus` ?string, `trackingLastCheckedAt` ?\DateTimeInterface, `trackingDeliveredAt` ?\DateTimeInterface, `reviewEmailSentAt` ?\DateTimeInterface, `noteInterne` ?string text + getters/setters)
- Create: migration via `php bin/console make:migration`

**Interfaces (produit pour les phases suivantes):** champs/types exactement comme spec §2.3 ; enums string-backed comme les existants (`CommandeState` pattern, avec `label()` FR où utile : BannerType, NotificationType, TrackingStatus, statuts atelier).

- [ ] Écrire les 8 entités + enums (suivre le style des entités existantes : attributs ORM, types stricts, index via `#[ORM\Index]` — index de la spec §2.3).
- [ ] `php bin/console make:migration` puis relire la migration générée (additive uniquement : 8 CREATE TABLE + ALTER commande ADD ×8 ; AUCUN DROP/CHANGE sur l'existant).
- [ ] `php bin/console doctrine:migrations:migrate -n` puis `php bin/console doctrine:schema:validate` → `[OK]` mapping + sync.
- [ ] Commit `feat(gestion): entités admin v2 + migration additive`.

### Task 1.3: Firewall gestion

**Files:**
- Modify: `config/packages/security.yaml`
- Create: `src/Security/GestionAuthenticator.php`
- Modify: `config/packages/framework.yaml` (rien si rate_limiter auto) — `login_throttling` requiert `symfony/rate-limiter` : vérifier `composer show symfony/rate-limiter`, sinon `composer require symfony/rate-limiter:7.2.*`.

**security.yaml — état final voulu :**

```yaml
security:
    password_hashers:
        App\Entity\Admin: 'bcrypt'
        App\Entity\Client: 'bcrypt'
    providers:
        users_in_memory: { memory: null }
        gestion_admins:
            entity: { class: App\Entity\Admin, property: pseudo }
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        gestion:
            pattern: ^/gestion
            lazy: true
            provider: gestion_admins
            custom_authenticator: App\Security\GestionAuthenticator
            login_throttling: { max_attempts: 5, interval: '1 minute' }
            logout: { path: gestion_logout, target: gestion_login }
        main:
            lazy: true
            provider: users_in_memory
    access_control:
        - { path: ^/gestion/login, roles: PUBLIC_ACCESS }
        - { path: ^/gestion, roles: ROLE_ADMIN }
```

**GestionAuthenticator (complet) :**

```php
<?php
namespace App\Security;

use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\SecurityRequestAttributes;
use Symfony\Component\Security\Http\Util\TargetPathTrait;

class GestionAuthenticator extends AbstractLoginFormAuthenticator
{
    use TargetPathTrait;
    public const LOGIN_ROUTE = 'gestion_login';

    public function __construct(private UrlGeneratorInterface $urlGenerator) {}

    public function authenticate(Request $request): Passport
    {
        $pseudo = (string) $request->request->get('pseudo', '');
        $request->getSession()->set(SecurityRequestAttributes::LAST_USERNAME, $pseudo);
        return new Passport(
            new UserBadge($pseudo),
            new PasswordCredentials((string) $request->request->get('password', '')),
            [new CsrfTokenBadge('authenticate', (string) $request->request->get('_csrf_token'))]
        );
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) {
            // Cible interne uniquement (le firewall ne stocke que des chemins de ce host)
            return new RedirectResponse($targetPath);
        }
        return new RedirectResponse($this->urlGenerator->generate('gestion_dashboard'));
    }

    protected function getLoginUrl(Request $request): string
    {
        return $this->urlGenerator->generate(self::LOGIN_ROUTE);
    }
}
```

- [ ] Appliquer security.yaml + authenticator. Note : la migration de session à la connexion est faite nativement par le composant security (`session_fixation_strategy: migrate` par défaut).
- [ ] Test fonctionnel `tests/Gestion/SecurityTest.php` : `test_gestion_redirects_anonymous_to_login` (GET `/gestion` → 302 vers `/gestion/login`), `test_login_wrong_password_shows_error` (POST mauvais mdp → redirect login + flash erreur générique), `test_login_success_reaches_dashboard` (admin fixtures → 302 `/gestion` → 200), `test_throttling_after_5_attempts` (6e tentative → TooManyLoginAttempts → message). Base de test : `dama` absent → utiliser fixtures programmatiques dans `setUp` (créer Admin `testadmin` hashé) + `.env.test` DB dédiée `vulcan_affutage_test` (créer : `doctrine:database:create --env=test` + `doctrine:migrations:migrate -n --env=test`).
- [ ] `php bin/phpunit tests/Gestion/SecurityTest.php` → PASS.
- [ ] Commit `feat(gestion): firewall dédié + authenticator throttlé`.

### Task 1.4: Chrome admin (base template + CSS/JS) + page login

**Files:**
- Create: `templates/gestion/_base.html.twig` (document complet autonome : fonts trio DA 2.0, `ds-v2-toast.css`, `ds-admin.css`, favicon existant, `body.adm`, sidebar + topbar + `{% block content %}`, `ds-v2-toast.js` + `ds-admin.js`, blocks `title`, `page_title`, `content`, `scripts`)
- Create: `templates/gestion/login.html.twig` (hors chrome : carte centrée fond forge, logo, champs pseudo/mdp floating-label, CSRF `authenticate`, erreur générique, throttle message)
- Create: `templates/gestion/dashboard.html.twig` (squelette : rangée 5 `.adm-kpi` à 0, zones vides « À traiter », « Notifications », placeholder graphique)
- Create: `public/styles/ds-admin.css` (§ tokens copiés de `ds-v2.css:7-66` + chrome : `.adm-shell` grid sidebar 248px/contenu, `.adm-side` (fond `--v2-forge`, nav verticale, liens avec icônes SVG stroked 24px, état actif = fond basalt + liseré gauche vert 3px, badges `.adm-side-badge` mono), `.adm-top` (fond steel-100, borne basse steel-200, recherche, cloche, ⚡, « Voir le site », compte), `.adm-main` (padding clamp, fond steel-050), atomes : `.adm-card`, `.adm-kpi` (valeur Fraunces 2rem, delta vert/rouge mono), `.adm-table`, `.adm-badge` (variantes par statut commande/campagne — couleurs mappées sur chips v2 : vert dispo, ember expo/promo, ink-soft neutre, rouge `#b23c2a` erreur), `.adm-tabs`, `.adm-form` (reprend pattern `.chk-field` floating label), `.adm-btn` (alias visuel `.nhp-btn` recopié), `.adm-modal` (overlay + carte, focus trap simple), `.adm-timeline`, `.adm-gauge`, `.adm-empty` (états vides illustrés texte), responsive < 900px : sidebar escamotable via bouton burger topbar)
- Create: `public/scripts/ds-admin.js` (IIFE : toggle sidebar mobile, `admConfirm(message, onOk)` → `.adm-modal` promesse (remplace `confirm()`), helper `admFetch(url, {method:'POST', body, csrf})` → JSON + toast erreur réseau, copie presse-papiers `data-adm-copy` + toast « Copié », polling notifications désactivé tant que endpoint absent (guard silencieux))
- Create: `src/Controller/Gestion/GestionSecurityController.php` (GET `gestion_login` — si déjà authentifié → redirect dashboard ; route `gestion_logout` vide gérée par firewall) et `src/Controller/Gestion/DashboardController.php` (`gestion_dashboard`, rend squelette)

**Interfaces produites :** `_base.html.twig` blocks (`page_title`, `content`, `scripts`) ; `admFetch`/`admConfirm`/`showNotification` disponibles sur toutes pages gestion ; classes `.adm-*` listées ci-dessus. Nav sidebar : entrées Tableau de bord `/gestion`, Commandes `/gestion/commandes`, Produits `/gestion/produits`, Ateliers `/gestion/ateliers`, Bandeaux `/gestion/bandeaux`, Statistiques `/gestion/statistiques`, Paramètres `/gestion/parametres` (routes phase-stubbées au fur et à mesure — n'afficher que les routes existantes : garder la nav dans un include `_nav.html.twig` mis à jour par phase).

- [ ] Implémenter fichiers ci-dessus. Login : POST vers `gestion_login` (l'authenticator intercepte).
- [ ] Test visuel navigateur (390 + 1440) : login → dashboard, sidebar/topbar propres, 0 erreur console, toasts fonctionnels (`showNotification` test manuel via console).
- [ ] Étendre `SecurityTest` : `test_login_page_renders` (200, champ pseudo présent), `test_logout` (auth → POST logout → anonyme). PASS.
- [ ] Commit `feat(gestion): chrome admin DA 2.0 + login + dashboard squelette`.

### Task 1.5: StatVisitListener skip /gestion

**Files:** Modify: `src/EventListener/StatVisitListener.php` (ajouter `/gestion` aux préfixes ignorés, à côté du skip `/admin` existant).

- [ ] Modifier + vérifier par test unitaire existant du listener s'il y en a un, sinon assert rapide : GET `/gestion/login` ne crée pas de `StatVisit` (test fonctionnel count avant/après).
- [ ] Commit `fix(gestion): stats v1 ignorent /gestion`.

---

## Phase 2 — Commandes + tracking La Poste + emails

### Task 2.1: Contrats tracking + MockTrackingProvider

**Files:**
- Create: `src/Service/Tracking/TrackingResult.php`, `src/Service/Tracking/TrackingEvent.php` (DTOs readonly), `src/Service/Tracking/TrackingProviderInterface.php`, `src/Service/Tracking/MockTrackingProvider.php`
- Test: `tests/Gestion/Tracking/MockTrackingProviderTest.php`

**Contrats exacts :**

```php
final readonly class TrackingEvent {
    public function __construct(
        public string $code,          // code transporteur brut (ex. 'DR1')
        public string $label,         // libellé FR affichable
        public \DateTimeImmutable $date,
    ) {}
    public function toArray(): array;          // ['code','label','date' => ISO8601]
    public static function fromArray(array $a): self;
}

final readonly class TrackingResult {
    public function __construct(
        public TrackingStatus $status,
        public string $statusLabel,
        /** @var TrackingEvent[] triés date ASC */
        public array $events,
        public bool $isFinal,
        public ?\DateTimeImmutable $deliveredAt,
        public ?string $trackingUrl,   // lien public laposte.fr
    ) {}
}

interface TrackingProviderInterface {
    public function supports(string $carrier): bool;          // Carrier::LAPOSTE->value
    public function fetch(string $trackingNumber): TrackingResult; // jette TrackingException (création : classe simple avec ->kind: 'auth'|'not_found'|'quota'|'network')
}
```

**MockTrackingProvider** : actif via alias de service (voir 2.2). Scénario déterministe : dernier caractère du numéro % 4 → profil (0 = livré, 1 = en transit, 2 = problème puis reprise, 3 = disponible retrait) ; progression par « âge » = nb d'heures depuis un epoch dérivé du hash du numéro, événements espacés de 12 h. Toujours ≥ 1 événement `DR1`. `supports('laposte') === true`.

- [ ] Tests d'abord : `test_supports_laposte_only`, `test_deterministic_same_number_same_result`, `test_delivered_scenario_final_with_date`, `test_events_sorted_asc`. FAIL.
- [ ] Implémenter. PASS. Commit `feat(gestion): contrats tracking + provider mock`.

### Task 2.2: LaPosteTrackingProvider

**Files:**
- Create: `src/Service/Tracking/LaPosteTrackingProvider.php`, `src/Service/Tracking/TrackingException.php`
- Modify: `config/services.yaml` (binder `$laposteApiKey: '%env(default::string:LAPOSTE_API_KEY)%'` ; alias `TrackingProviderInterface` → LaPoste si clé non vide ET `APP_TRACKING_MOCK != 1`, sinon Mock — via factory simple `TrackingProviderFactory::create()` enregistrée comme service factory)
- Modify: `.env` (ajouter bloc documenté `LAPOSTE_API_KEY=` vide + `APP_TRACKING_MOCK=1`)
- Test: `tests/Gestion/Tracking/LaPosteTrackingProviderTest.php` (MockHttpClient Symfony)

**Cœur du provider :**

```php
public function fetch(string $trackingNumber): TrackingResult
{
    $resp = $this->http->request('GET',
        'https://api.laposte.fr/suivi/v2/idships/'.rawurlencode($trackingNumber),
        ['headers' => ['X-Okapi-Key' => $this->apiKey, 'Accept' => 'application/json'],
         'query' => ['lang' => 'fr_FR'], 'timeout' => 10]);
    $code = $resp->getStatusCode();
    if (401 === $code || 403 === $code) throw new TrackingException('auth');
    if (404 === $code) return new TrackingResult(TrackingStatus::INCONNU, TrackingStatus::INCONNU->label(), [], false, null, $this->publicUrl($trackingNumber));
    if (429 === $code) throw new TrackingException('quota');
    if ($code >= 400) throw new TrackingException('network');
    $data = $resp->toArray(false);
    $shipment = $data['shipment'] ?? [];
    // events[] : [{code, label, date}] ; isFinal ; deliveryDate éventuel
    // mapping codes → TrackingStatus : voir MAP ci-dessous ; statut global = statut du DERNIER événement mappé
}

private const MAP = [
    'DR1'=>'PRIS_EN_CHARGE','DR2'=>'PRIS_EN_CHARGE','PC1'=>'PRIS_EN_CHARGE','PC2'=>'PRIS_EN_CHARGE',
    'ET1'=>'EN_TRANSIT','ET2'=>'EN_TRANSIT','ET3'=>'EN_TRANSIT','ET4'=>'EN_TRANSIT','EP1'=>'EN_TRANSIT',
    'DO1'=>'EN_TRANSIT','DO2'=>'EN_TRANSIT','DO3'=>'EN_TRANSIT',
    'MD2'=>'EN_LIVRAISON','AG1'=>'DISPONIBLE_RETRAIT',
    'DI1'=>'LIVRE','DI2'=>'LIVRE','PB1'=>'PROBLEME','PB2'=>'PROBLEME','ND1'=>'PROBLEME','RE1'=>'RETOUR',
]; // code inconnu → statut courant conservé, event gardé avec son label API
// publicUrl: https://www.laposte.fr/outils/suivre-vos-envois?code={num}
```

**IMPORTANT exécutant :** au moment du premier test avec la vraie clé (Phase 8), comparer la réponse réelle au parsing et ajuster (`shipment.event[]` vs `shipment.timeline[]`, champ date exact). Le test unitaire fige le contrat avec des payloads JSON d'exemple embarqués dans le test.

- [ ] Tests d'abord (MockHttpClient : payload 200 livré, 200 en transit, 404, 401, 429) : `test_delivered_maps_final`, `test_transit_not_final`, `test_404_returns_inconnu_without_throw`, `test_401_throws_auth`, `test_429_throws_quota`. FAIL → implémenter → PASS.
- [ ] Factory + services.yaml + `.env`. Vérifier bascule : `APP_TRACKING_MOCK=1` (défaut dev) → Mock injecté ; clé réelle + mock=0 → LaPoste.
- [ ] Commit `feat(gestion): provider La Poste Okapi + bascule mock/réel`.

### Task 2.3: TrackingSyncService + AdminNotification

**Files:**
- Create: `src/Service/Tracking/TrackingSyncService.php`, `src/Service/NotificationService.php`
- Test: `tests/Gestion/Tracking/TrackingSyncServiceTest.php`

**Contrats :**

```php
class NotificationService {
    public function notify(NotificationType $type, string $titre, ?string $message = null, ?string $url = null): AdminNotification; // persiste + flush
    public function countUnread(): int;
    public function markRead(int $id): void;
    public function markAllRead(): void;
}

class TrackingSyncService {
    /** @return bool true si nouveaux événements */
    public function sync(Commande $c): bool;
    /** @return int commandes synchronisées */
    public function syncAllDue(): int; // critères spec §2.4 ; catch TrackingException par commande (auth → 1 notif SYSTEME globale + stop)
}
```

Transitions dans `sync()` (spec §2.4) : nouveaux events → `trackingEvents`/`trackingLastStatus`/`trackingLastCheckedAt` ; commande `RECEIVED|PENDING` + ≥1 event → `SHIPPED` ; statut `LIVRE` → `DELIVERED` + `trackingDeliveredAt` + notif `LIVRAISON` (« Colis livré — commande {ref} ») + si `notify.client_on_delivery`=1 et email client → `AppMailer::sendOrderDelivered` ; `PROBLEME` (nouveau) → notif `SUIVI_ANOMALIE`. Ne JAMAIS rétrograder un statut final.

- [ ] Tests (EM mocké ou kernel + DB test, provider stub) : `test_new_events_persisted`, `test_first_event_moves_received_to_shipped`, `test_delivered_sets_status_date_and_notifies`, `test_delivered_sends_client_email_when_setting_on`, `test_probleme_creates_anomaly_notification_once`, `test_no_downgrade_after_delivered`, `test_sync_all_due_filters`. FAIL → implémenter (dépend Task 4.1 SettingsService ? NON — lire setting via repository Setting directement pour éviter dépendance de phase : injecter `SettingRepository` et défaut '1') → PASS.
- [ ] Commit `feat(gestion): sync suivi + notifications admin`.

### Task 2.4: AppMailer + templates emails v2

**Files:**
- Create: `src/Service/Mail/AppMailer.php` ; `templates/emails/v2/_layout.html.twig` (table 600px, header forge + logo `public/assets/logo-50p.jpg` en cid, accent vert, footer coordonnées via settings fallback), `commande_expediee.html.twig`, `commande_statut.html.twig`, `commande_livree.html.twig`, `avis_demande.html.twig`
- Test: `tests/Gestion/Mail/AppMailerTest.php`

**Contrat :**

```php
class AppMailer {
    public function sendOrderShipped(Commande $c): void;   // sujet: "Votre colis est en route ! — Vulcan'Affûtage"
    public function sendOrderStatusChanged(Commande $c): void; // sujet: "Mise à jour de votre commande {ref}"
    public function sendOrderDelivered(Commande $c): void; // sujet: "Votre colis est arrivé !"
    public function sendReviewRequest(Commande $c): void;  // sujet: "Votre avis compte pour l'atelier"
}
// Tous : to = $c->getClient()->getEmail() ; no-op silencieux si email vide (défensif).
// commande_expediee : si carrier=laposte → n° + bouton lien publicUrl ; si libre + trackingUrl → bouton ;
// si libre sans URL → "Colis envoyé avec le numéro de suivi suivant : {num}".
// avis_demande : bouton vers setting links.google_review ; no-op si setting vide.
```

- [ ] Tests (kernel, `MAILER_DSN=null://null`, `getMailerMessage()` asserts) : `test_shipped_laposte_contains_number_and_link`, `test_shipped_libre_without_url_plain_wording`, `test_no_email_no_send`, `test_review_contains_google_link`, `test_review_skipped_if_no_link_setting`. FAIL → implémenter → PASS.
- [ ] Commit `feat(gestion): mailer centralisé + emails transactionnels DA 2.0`.

### Task 2.5: ReviewRequestService

**Files:** Create: `src/Service/Tracking/ReviewRequestService.php` ; Test: `tests/Gestion/Tracking/ReviewRequestServiceTest.php`

```php
class ReviewRequestService {
    public function sendDue(): int; // spec §2.4 : DELIVERED + deliveredAt<=now-2j + reviewEmailSentAt NULL + email + setting non vide → AppMailer::sendReviewRequest + marque reviewEmailSentAt
}
```

- [ ] Tests : `test_sends_after_two_days`, `test_not_before_two_days`, `test_idempotent`, `test_skipped_without_email`. FAIL → implémenter → PASS. Commit `feat(gestion): relance avis auto J+2`.

### Task 2.6: CronCommand

**Files:** Create: `src/Command/CronCommand.php` (`app:cron`) ; Test: `tests/Gestion/CronCommandTest.php` (CommandTester)

Verrou `symfony/lock` (flock store var/) ; séquence : `TrackingSyncService::syncAllDue()`, `ReviewRequestService::sendDue()`, hooks stats ajoutés Phase 6 (`AnalyticsQueryService::computeDaily(hier)` si absent + `rotateSalt()` + `purgeOldHits()` — insérés à T6.3, le command est écrit extensible dès maintenant avec ces appels en commentaires TODO-phase-6 NON — écrire le command en 2 temps : phase 2 version sans stats, T6.3 l'étend réellement). Sortie : résumé compté par étape, exit 0 même si une étape échoue (log + notif SYSTEME), exit 1 seulement si verrou impossible.

- [ ] Test : `test_runs_and_reports_counts`, `test_lock_prevents_parallel`. Implémenter. PASS. Commit `feat(gestion): commande cron idempotente`.

### Task 2.7: Écrans commandes (liste + détail)

**Files:**
- Create: `src/Controller/Gestion/CommandeController.php` (`gestion_commandes` GET liste ; `gestion_commande_detail` GET par `reference`)
- Create: `templates/gestion/commandes/index.html.twig`, `detail.html.twig`, `_status_badge.html.twig` (map CommandeState → `.adm-badge--*`)
- Modify: `templates/gestion/_nav.html.twig` (activer entrée Commandes + badge count RECEIVED)

Liste : onglets (`?tab=a-traiter|en-cours|expediees|terminees|toutes` → filtres : RECEIVED / PENDING+WAITING / SHIPPED / DELIVERED / tout sauf rien) ; recherche `?q=` (référence LIKE, nom/prénom/email client LIKE via join) ; KnpPaginator 25/page ; colonnes : réf (mono), date, client, total, livraison, statut badge, suivi (pastille si présent), action Voir. Détail : cartes client (nom/email/tél adresse), adresse livraison, paiement (PayPal ids, statut), lignes produits (vignette existante `/miniature/{id}`), total/frais ; carte suivi (voir 2.8) ; carte actions statut ; note interne (textarea + save) ; lien facture PDF existant (`FactureController` route — vérifier son nom exact à l'exécution et réutiliser).

- [ ] Implémenter + test fonctionnel `tests/Gestion/CommandeScreensTest.php` : `test_list_requires_auth`, `test_tabs_filter_counts`, `test_search_by_reference`, `test_detail_shows_products_and_client`. PASS.
- [ ] Vérif navigateur (2 viewports, 0 erreur console). Commit `feat(gestion): écrans commandes`.

### Task 2.8: Actions commandes (statut, suivi, note) + checkbox communication

**Files:**
- Modify: `src/Controller/Gestion/CommandeController.php` (endpoints POST JSON : `gestion_api_commande_status` `/gestion/api/commandes/{reference}/status` {status, notifyClient} ; `gestion_api_commande_tracking` `/gestion/api/commandes/{reference}/tracking` {carrier: 'libre'|'laposte', number, url?, notifyClient} — valide format La Poste `^[A-Z0-9]{10,15}$` i, déclenche `TrackingSyncService::sync` immédiat si laposte ; `gestion_api_commande_note` {note} ; `gestion_api_commande_track_now` → sync + retourne timeline fraîche)
- Modify: `templates/gestion/commandes/detail.html.twig` + `public/scripts/ds-admin.js` (module page détail : select statut, **checkbox « Communiquer ce changement au client » — `disabled` + tooltip si email absent, décochée par défaut**, select transporteur → champs conditionnels (numéro seul / numéro+lien), timeline `.adm-timeline` rendue depuis `trackingEvents`, bouton « Vérifier maintenant », clic numéro = copie)

Règles : CSRF header ; statut validé `CommandeState::tryFrom` ; notifyClient=true + email → `AppMailer::sendOrderStatusChanged` OU `sendOrderShipped` (si l'action est la pose du n° de suivi, l'email envoyé est « expédié ») ; réponse JSON {ok, newStatus/label, timeline?} ; toasts sur succès/erreur.

- [ ] Tests fonctionnels : `test_status_update_persists`, `test_status_update_with_notify_sends_email`, `test_notify_ignored_when_no_client_email` (défensif : forcer email vide en fixture), `test_tracking_laposte_valid_format_syncs_timeline` (mock provider), `test_tracking_invalid_format_400`, `test_tracking_libre_with_url_saved`, `test_csrf_missing_403`. FAIL → implémenter → PASS.
- [ ] Vérif navigateur : cycle complet sur commande fixture (statut → email profiler dev toolbar, pose suivi mock → timeline visible, copie n°).
- [ ] Commit `feat(gestion): actions commande + communication client conditionnelle`.

---

## Phase 3 — Produits

### Task 3.1: Liste produits

**Files:** Create: `src/Controller/Gestion/ProduitController.php` (`gestion_produits`), `templates/gestion/produits/index.html.twig` ; Modify: `_nav.html.twig`.

Table : vignette (`/miniature/{id}`, fallback), libellé, type (`ProductType` label), modèle, prix (mono), statut boutique badge (vente/vendu/expo), visibilité toggle inline, date, actions (Éditer / Supprimer modal). Filtres : type, statut boutique, visibilité, recherche libellé. Pagination 30. Bouton primaire « Nouveau produit ». Lien secondaire « Slots page d'accueil ».

- [ ] Implémenter + `tests/Gestion/ProduitScreensTest.php::test_list_filters_and_search`. Vérif navigateur. Commit `feat(gestion): liste produits`.

### Task 3.2: Création / édition + images

**Files:**
- Create: routes `gestion_produit_new` (GET/POST) + `gestion_produit_edit` (GET/POST) dans `ProduitController`, `templates/gestion/produits/form.html.twig` ; `src/Service/ProductImageManager.php`
- Endpoints images : `POST /gestion/api/produits/{id}/images` (upload multi), `POST .../images/reorder` {order:[n...]}, `POST .../images/{n}/delete`

`ProductImageManager` : reprend EXACTEMENT le durcissement existant (`GestionProduitController::safeImageExtension` — finfo whitelist jpg/png/webp, ≤5 Mo) extrait en service réutilisable ; `list(int $id): array` (probe fichiers), `add(int $id, UploadedFile[] $files)`, `reorder(int $id, int[] $order)` (renommage transactionnel via noms temporaires pour éviter collisions), `remove(int $id, int $n)` (+ reindex trous). L'ancien contrôleur v1 n'est PAS modifié.

Form : tous champs entité (libelle, prix, description, type select enum, modèle select TypeCouteau, variante select, statut boutique, visibilité) + section images (grille vignettes, drag pour réordonner OU boutons ↑↓ — boutons suffisent, upload multiple, suppression modal, visionneuse plein écran simple).

- [ ] Tests : `test_create_product_persists`, `test_edit_updates`, `test_image_upload_rejects_php_disguised` (fichier .php renommé .jpg → 400), `test_reorder_renames_consistently` (fixtures fichiers tmp), `test_delete_image_reindexes`. FAIL → implémenter → PASS.
- [ ] Vérif navigateur create/edit/upload/reorder/delete réels. Commit `feat(gestion): CRUD produit + gestion images durcie`.

### Task 3.3: Toggle / delete / slots home

**Files:** Modify `ProduitController` : `POST /gestion/api/produits/{id}/toggle` (ProductState flip), `POST /gestion/api/produits/{id}/delete` (refus si `ProductCommande` existant → 409 + message, sinon delete + purge images), page `gestion_produits_slots` (GET/POST — liste ordonnable 20 slots max, réutilise `home_priority`, logique transactionnelle inspirée de `ApiAdminController::update-home-products` sans la toucher) + template `slots.html.twig`.

- [ ] Tests : `test_toggle_flips_visibility`, `test_delete_blocked_when_ordered_409`, `test_delete_removes_files`, `test_slots_assignment_and_dedup`. Implémenter. PASS. Vérif navigateur. Commit `feat(gestion): visibilité, suppression protégée, slots home`.

---

## Phase 4 — Paramètres + bandeaux (+ consommation client)

### Task 4.1: SettingsService + Twig `setting()`

**Files:** Create: `src/Service/SettingsService.php` ; Modify: `src/Twig/AppExtension.php` (fonction `setting(key, default='')`) ; Test: `tests/Gestion/SettingsServiceTest.php`.

```php
class SettingsService {
    public function get(string $key, ?string $default = null): ?string; // cache.app, clé 'setting.'.$key, TTL 3600
    public function getJson(string $key, array $default = []): array;
    public function set(string $key, ?string $value): void;             // upsert + invalidation cache
    public function setMany(array $kv): void;
}
```

Défauts embarqués (constante) = valeurs actuelles du site : téléphone `+33 4 73 37 29 05`, email contact existant, horaires « Mardi–Vendredi 9h–12h / 14h–19h » au format `hours.week` JSON `{mon:{closed:true},tue:{am:['09:00','12:00'],pm:['14:00','19:00']},...}`, `notify.client_on_delivery`='1'. `get()` retombe sur défaut si ligne absente.

- [ ] Tests : `test_get_returns_default_when_missing`, `test_set_then_get`, `test_cache_invalidated_on_set`, `test_json_roundtrip`. FAIL → implémenter → PASS. Commit `feat(gestion): settings clé-valeur cachés + twig setting()`.

### Task 4.2: Écran Paramètres + CRUD livraisons

**Files:** Create: `src/Controller/Gestion/ParametreController.php` (`gestion_parametres` GET + POST sections : `contact`, `hours`, `exceptional`, `links`, `notify` — un POST par section, CSRF) + gestion `Livraison` (add/edit/toggle visibilité, POST, validation prix decimal ≥0) ; `templates/gestion/parametres/index.html.twig` (sections cartes ; horaires : 7 lignes jour × (fermé checkbox | plages matin/aprem time inputs) ; fermetures exceptionnelles : liste dynamique date+libellé+ferme/horaires, add/remove ; liens : google_review, gbp_dashboard (+ bouton « Ouvrir la fiche Google » target _blank), instagram, facebook ; livraisons : table inline edit).

- [ ] Tests : `test_save_contact_persists_settings`, `test_hours_json_valid`, `test_exceptional_add_remove`, `test_livraison_create_and_toggle`. Implémenter. PASS. Vérif navigateur. Commit `feat(gestion): paramètres site + livraisons`.

### Task 4.3: Consommation client des settings

**Files:** Modify: `templates/base.html.twig` (footer : téléphone/email/horaires → `setting()` avec fallback actuel en défaut du service — le rendu reste identique tant que rien n'est modifié en admin), template contact v2 (`templates/contact/newindex.html.twig`), section contact homepage (`templates/accueil/newhomepage.html.twig`), JSON-LD (bloc `structuredData` where présent). Fermetures exceptionnelles : sur contact v2 + homepage, encart discret « Fermeture exceptionnelle le {date} — {libellé} » si une date ≥ aujourd'hui existe (7 prochains jours).

- [ ] Vérif : diff visuel AVANT/APRÈS neutre (mêmes valeurs rendues) ; changer téléphone en admin → visible sur les 3 zones + JSON-LD. Test fonctionnel `test_homepage_renders_setting_phone`. Commit `feat(gestion): coordonnées/horaires pilotés par paramètres`.

### Task 4.4: Bandeaux — service + CRUD admin

**Files:** Create: `src/Service/BannerService.php` (`getActiveBanner(): ?Banner` — actif, now∈[start,end], ORDER priorite DESC id DESC, cache 60 s `cache.app`, invalidation à l'écriture), `src/Controller/Gestion/BannerController.php` (liste + create/edit/delete/toggle POST), `templates/gestion/bandeaux/index.html.twig`, `form.html.twig` (aperçu live JS : rendu exact du markup client, bascule statique/défilant, styles type promo/info) ; Test: `tests/Gestion/BannerServiceTest.php`.

Badges d'état liste (calculés) : Programmé (start>now), Actif (now dans plage + actif), Expiré (end<now), Désactivé (!actif).

- [ ] Tests : `test_active_banner_selected_by_priority`, `test_inactive_or_out_of_window_excluded`, `test_cache_invalidated_on_save`. FAIL → implémenter → PASS. Vérif navigateur (aperçu). Commit `feat(gestion): campagnes bandeau + aperçu live`.

### Task 4.5: Bandeau côté client

**Files:** Create: `templates/_banner.html.twig`, `public/styles/va-banner.css`, extrait JS inline (dismiss) ; Modify: `templates/base.html.twig` (include tout en haut du `<body>` + link CSS), variable Twig globale `active_banner` (via `config/packages/twig.yaml` global service OU rendu contrôleur embarqué — CHOISIR : Twig global pointant sur `BannerService` méthode, simple).

Markup : `<div class="va-banner va-banner--{type} va-banner--{mode}" data-banner-id="{id}">` texte échappé ; mode défilant : contenu dupliqué ×2 dans `.va-banner-track` animation `translateX(-50%)` linéaire infinie (durée ∝ longueur), `animation-play-state: paused` au hover, `@media (prefers-reduced-motion: reduce)` → statique ; bouton `.va-banner-close` (×) → `sessionStorage['vaBannerDismiss-'+id]`, masque `.va-banner`. Décalage : `body{--va-banner-h: 34px}` quand présent, `.header-big`/`.header-small` top offset via règle additive (vérifier headers fixes existants — position et top actuels — et compenser proprement aux 2 formats).

- [ ] Vérif navigateur : bandeau promo actif fixture visible sur `/`, `/produits`, pages v2 ; défilement fluide ; fermeture persiste dans l'onglet ; header non recouvert (390+1440) ; aucune CLS visible majeure ; `prefers-reduced-motion` → statique. Test fonctionnel : `test_banner_rendered_when_active`, `test_no_banner_when_none_active`.
- [ ] Commit `feat(client): bandeau événementiel programmable`.

---

## Phase 5 — Ateliers

### Task 5.1: CRUD ateliers

**Files:** Create: `src/Controller/Gestion/AtelierController.php` (`gestion_ateliers` liste cartes + `gestion_atelier_new`/`_edit` GET/POST + toggle actif POST), `templates/gestion/ateliers/index.html.twig`, `form.html.twig` ; Modify `_nav.html.twig`.

- [ ] Test `test_atelier_crud`. Implémenter. Vérif navigateur. Commit `feat(gestion): ateliers CRUD`.

### Task 5.2: Créneaux + réservations

**Files:** Modify `AtelierController` : section planning sur `gestion_ateliers` (créneaux à venir groupés par mois, jauge `.adm-gauge` places prises/capacité), `gestion_creneau_new` POST (date/heure, capacité défaut = atelier), `gestion_creneau_detail` GET (liste réservations + total places), POST résa manuelle (nom, prénom, email?, tél?, places, statut CONFIRMEE, source ADMIN — refus si dépasse capacité restante → 409), POST annulation résa (motif optionnel stocké en message notif), POST statut créneau (fermer/annuler — annuler notifie liste des résas à l'écran pour rappel téléphonique, pas d'email auto). `NotificationService::notify(RESERVATION, ...)` à chaque résa créée. Templates : `creneau.html.twig`.

- [ ] Tests : `test_creneau_capacity_enforced_409`, `test_reservation_manual_created_and_notifies`, `test_cancel_frees_capacity`. FAIL → implémenter → PASS. Vérif navigateur. Commit `feat(gestion): créneaux + réservations manuelles`.

---

## Phase 6 — Statistiques

### Task 6.1: Collecte — AnalyticsCollector + endpoint /t

**Files:** Create: `src/Service/Analytics/AnalyticsCollector.php`, `src/Controller/AnalyticsController.php` (`POST /t`, nom `analytics_beacon`, hors firewall gestion, pas de session requise, réponse 204 toujours) ; Test: `tests/Gestion/Analytics/AnalyticsCollectorTest.php`.

```php
class AnalyticsCollector {
    public function collectFromRequest(Request $r): void; // beacon: pageview|add_to_cart
    public function recordServerEvent(string $eventType, ?Produit $p = null, ?string $path = null): void; // purchase|account_created
    public function currentSalt(): string;   // setting interne 'analytics.salt' + 'analytics.salt.date' ; rotation si date != today (lazy à chaque hit)
    public function visitorHash(Request $r): string; // hash('sha256', salt.'|'.ip.'|'.userAgent)
    public function isBot(string $ua): bool; // regex: /bot|crawl|spider|slurp|headless|lighthouse|pingdom|facebookexternalhit|preview|scan|curl|wget|python-requests|monitor/i ; UA vide → bot
    public function isRateLimited(string $hash): bool; // >60 hits / 10 min (COUNT sur analytics_hit indexé visitorHash+hitAt)
}
```

Payload beacon JSON : `{p: path(≤500), t: pageType, pid?: int, c?: categorie, e: 'pageview'|'add_to_cart', r?: referrerHost(≤150)}` — validation stricte types/longueurs/enum, sinon drop silencieux (204). device par UA (mobile|tablet|desktop regex simple). Bots/ratelimited/chemins `/gestion|/admin` → drop. `purchase` : montant NON stocké dans hit (le CA vient des commandes) ; hit sert au funnel.

- [ ] Tests : `test_pageview_inserted_with_hash`, `test_bot_ua_dropped`, `test_rate_limit_drops`, `test_salt_rotates_daily` (horloge injectable `ClockInterface`), `test_same_visitor_same_day_same_hash`, `test_hash_differs_across_days`, `test_invalid_payload_dropped_204`. FAIL → implémenter → PASS. Commit `feat(stats): collecte beacon anti-bot unicité quotidienne`.

### Task 6.2: Beacon JS + événements server-side

**Files:** Create: `public/scripts/ds-analytics.js` ; Modify: `templates/base.html.twig` (script defer + `data-` contexte : `data-page-type`, `data-produit-id` posés par les templates produit — modifier `templates/produit/…` v2 detail pour poser l'attribut), Modify: `src/Controller/PaymentController.php` (après capture COMPLETED : `recordServerEvent('purchase')` par produit acheté + 1 notif `VENTE` « Nouvelle vente ! {ref} — {total} € » via `NotificationService`), `src/Controller/ClientRegisterController.php` (inscription réussie : `recordServerEvent('account_created')`).

`ds-analytics.js` (≤60 lignes) : au `DOMContentLoaded` → `sendBeacon('/t', JSON pageview)` (pageType depuis `body[data-page-type]` sinon heuristique path : `/`→home, `/produit/`→produit, `/produits`→listing, `/panier`→panier, `/paiement`→checkout, sinon autre ; referrerHost = `new URL(document.referrer).host` si cross-host) ; wrap `window.addToCartAjax` existant (si défini) pour émettre `add_to_cart` avec pid avant l'appel original. Jamais chargé si path commence par `/gestion` ou `/admin` (guard template ET guard JS).

- [ ] Vérif navigateur : hits en base pour home/produit/listing, add_to_cart émis, aucun hit navigation admin. Tests fonctionnels : `test_purchase_event_recorded_on_capture` (simuler flux avec PayPal service mocké si possible — sinon test unitaire du hook), `test_account_created_event`. Commit `feat(stats): beacon front + événements serveur fiables`.

### Task 6.3: AnalyticsQueryService + agrégats + cron

**Files:** Create: `src/Service/Analytics/AnalyticsQueryService.php` ; Modify: `src/Command/CronCommand.php` (ajouter computeDaily veille manquante, rotation salt forcée, purge >90 j) ; Test: `tests/Gestion/Analytics/AnalyticsQueryServiceTest.php`.

```php
class AnalyticsQueryService {
    public function overview(\DateTimeImmutable $from, \DateTimeImmutable $to): array;
    // {visitors, pageviews, addToCarts, orders, revenue, conversionRate, accountsCreated, totalAccounts, botHitsExcluded? non stocké → omis}
    public function series(\DateTimeImmutable $from, \DateTimeImmutable $to): array;   // [{day, visitors, pageviews, orders, revenue}]
    public function topPages(...$range, int $limit = 20): array;    // [{path, uniques, views}]
    public function topProducts(...$range, int $limit = 20): array; // [{produitId, libelle, views, addToCarts, purchases}]
    public function topCategories(...$range): array; public function devices(...): array; public function referrers(..., int $limit = 15): array;
    public function visits(...$range): int; // sessions: épisodes >30min par hash — SQL: somme par hash de 1 + COUNT(gaps>30min) via variable window LAG(hitAt) — MySQL 8 OK
    public function computeDaily(\DateTimeImmutable $day): AnalyticsDaily; // upsert
    public function monthly(int $year): array; // agrégats par mois depuis AnalyticsDaily
    public function purgeOldHits(int $days = 90): int;
}
```

Visiteurs = `COUNT(DISTINCT visitorHash WHERE eventType='pageview')` ; orders/revenue depuis `Commande` (status ∈ RECEIVED..DELIVERED, createdAt dans plage) ; totalAccounts/created depuis `Client`.

- [ ] Tests (dataset seedé en setUp, ~200 hits contrôlés) : `test_unique_visitors_dedup_same_day` (même hash 5 pageviews → 1 visiteur), `test_sessions_split_on_30min_gap`, `test_top_products_funnel_counts`, `test_compute_daily_upsert_idempotent`, `test_purge_keeps_recent`, `test_monthly_rollup`. FAIL → implémenter → PASS.
- [ ] Étendre CronCommand + son test. Commit `feat(stats): requêtes analytiques + agrégats journaliers + purge`.

### Task 6.4: Écrans statistiques + rapports + export

**Files:** Create: `src/Controller/Gestion/StatistiqueController.php` (page + endpoints JSON `gestion_api_stats_overview|series|tops|reports` avec `?from&to|period=7d|30d|90d|365d`, export `gestion_stats_export_csv` — CSV `analytics_daily` de la plage), `templates/gestion/statistiques/index.html.twig` (sélecteur période + custom dates, cartes synthèse, canvas Chart.js principal visiteurs+ventes double axe, tables top pages/produits (funnel colonnes vues→panier→achat→taux)/catégories, donuts appareils, référents ; onglet Rapports : table journalière avec deltas, table mensuelle, bouton CSV, note méthodo « bots exclus, visiteurs uniques quotidiens sans cookie ») ; vendored `public/vendor/chartjs/chart.umd.min.js` (télécharger version 4.x, commit).

- [ ] Test fonctionnel : `test_stats_endpoints_shapes` (clés JSON attendues), `test_csv_export_headers`. Vérif navigateur (graphes rendus, périodes switchent, mobile OK). Commit `feat(gestion): écrans stats + rapports + CSV`.

---

## Phase 7 — Notifications UI + dashboard final + palette

### Task 7.1: Notifications UI

**Files:** Create: `src/Controller/Gestion/NotificationController.php` (`gestion_notifications` page ; `GET /gestion/api/notifications` {unread, items[10]} ; POST `.../read/{id}`, `.../read-all`), `templates/gestion/notifications/index.html.twig` (filtres type, pagination) ; Modify: `_base.html.twig` topbar (cloche + pastille + dropdown 8 dernières) + `ds-admin.js` (polling 60 s → maj pastille/dropdown, silencieux si offline).

- [ ] Tests : `test_feed_returns_latest_unread_count`, `test_mark_read`, `test_mark_all`. Implémenter. Vérif navigateur (générer notif via action commande). Commit `feat(gestion): centre de notifications + cloche temps quasi réel`.

### Task 7.2: Dashboard réel

**Files:** Modify: `src/Controller/Gestion/DashboardController.php` + `templates/gestion/dashboard.html.twig`.

KPIs : CA mois courant + delta % vs mois précédent (`AnalyticsQueryService`/Commande), commandes à traiter (RECEIVED count, lien onglet), visiteurs 7 j + delta vs 7 j précédents, comptes créés 31 j (+ total), prochaines réservations (7 j, count + prochaine date). « À traiter » : commandes RECEIVED (5 dernières, lien), anomalies suivi non lues, bandeaux expirant <48 h. Fil notifications (10). Sparklines : 2 mini-canvas Chart.js (visiteurs 14 j, ventes 14 j).

- [ ] Test : `test_dashboard_kpis_match_fixtures`. Vérif navigateur. Commit `feat(gestion): dashboard opérationnel`.

### Task 7.3: Palette Ctrl+K + raccourcis ⚡

**Files:** Modify: `_base.html.twig` (markup palette + dropdown ⚡ : Nouveau produit, Nouvelle campagne, Voir le site (target _blank), Fiche Google (setting links.gbp_dashboard, masqué si vide), Stats du jour), `ds-admin.js` (ouverture Ctrl+K / clic recherche topbar, navigation statique fuzzy sur routes gestion + recherche serveur), Create endpoint `GET /gestion/api/search?q=` (commandes par référence LIKE limit 5 + produits par libellé limit 5 → {type, label, url}).

- [ ] Test : `test_search_endpoint_finds_reference_and_product`. Vérif navigateur (clavier ↑↓ Enter Esc). Commit `feat(gestion): palette de commande + raccourcis discrets`.

---

## Phase 8 — Fixtures démo, tests complets, QA E2E, docs

### Task 8.1: DemoFixtures

**Files:** Create: `src/DataFixtures/DemoFixtures.php` (`static function getGroups(): ['demo']`), contenu spec §6 (3 livraisons, 8 clients mdp connu `Demo1234!`, ~25 commandes 60 j statuts variés + suivis mock cohérents avec MockProvider, 3 ateliers + créneaux 6 semaines + résas partielles, 2 bandeaux, settings défauts, 10 notifs, hits analytics 60 j réalistes : base 20-60 pageviews/j, pics samedi, ~8% add_to_cart, funnel produit pondéré par home_priority, `computeDaily` exécuté pour chaque jour passé). Déterministe (seed mt_srand fixe).

- [ ] `php bin/console doctrine:fixtures:load --group=demo --append -n` (⚠️ `--append` pour ne pas purger — documenter aussi le mode purge complet dev). Vérifier compte lignes chaque table.
- [ ] Commit `feat(gestion): jeu de données démo complet`.

### Task 8.2: Suite de tests complète

- [ ] `php bin/phpunit` intégral → 100% PASS (corriger ce qui casse, y compris non-régression : les tests des phases précédentes tournent tous).
- [ ] Commit `test(gestion): suite verte complète`.

### Task 8.3: QA E2E navigateur + non-régression client

- [ ] Serveur dev + DB demo. Parcours admin complet aux 2 viewports (390/1440), chaque module, chaque action clé (login, throttle visuel, dashboard, commandes cycle complet avec emails vérifiés en profiler, produits CRUD+images, ateliers résa+jauge, bandeaux création+aperçu, paramètres save+GBP bouton, stats toutes vues+CSV, notifications lu/tout lu, palette, logout). 0 erreur console partout.
- [ ] Non-régression client : `/`, `/presentation`, `/affutage`, `/produits`, `/produit/{id}`, `/panier`, `/paiement`, `/compte`, `/contact`, login/inscription client, ancien `/admin` complet (login + 3 écrans + APIs) → 200, 0 erreur console, bandeau visible/fermable, beacon actif, valeurs settings rendues.
- [ ] Test clé réelle La Poste : `APP_TRACKING_MOCK=0` + clé en `.env.local`, `bin/console app:cron` sur une commande avec un numéro réel/de test → ajuster parsing si écart constaté (cf. note Task 2.2), re-passer les tests. Remettre `APP_TRACKING_MOCK=1`.
- [ ] Corriger tout écart, re-vérifier, commit `fix(gestion): QA E2E`.

### Task 8.4: Docs + mémoire

**Files:** Create: `docs/GESTION-ADMIN-V2.md` (activation : env vars `LAPOSTE_API_KEY`/`APP_TRACKING_MOCK`, cron OVH `*/15 * * * * php bin/console app:cron`, comptes démo, architecture rapide, procédure bascule/retrait ancien admin) ; Modify: `PROD-CHECKLIST.md` (append section admin v2 : rien de nouveau versionné en secrets, rappel rotation) ; REWORK-TODO.md (cocher Piliers 5/6 réalisés via v2, noter Pilier 9 partiellement couvert côté admin).

- [ ] Écrire + commit `docs(gestion): guide admin v2 + checklists à jour`. Mettre à jour la mémoire projet (fichier memory `admin-v2` : état, comptes démo, commandes utiles).

---

## Self-Review (fait à l'écriture)

- **Couverture spec :** dashboard (7.2), commandes+carrier+checkbox (2.7/2.8), La Poste API+mock+statut auto+notifs arrivée (2.1–2.3), avis J+2 (2.5), produits (3.x), ateliers/créneaux/résas (5.x), bandeaux campagnes+types+défilement (4.4/4.5), infos site+horaires+exceptionnel+GBP raccourci (4.1–4.3), stats anti-bot+unicité+rapports+comptes 31j (6.x), notifications+«Nouvelle vente» (6.2/7.1), raccourci discret ⚡+palette (7.3), livraisons seed (4.2/8.1), fixtures+commandes fictives (8.1), tests+E2E+non-régression (8.2/8.3), sécurité firewall/CSRF/throttle (1.3, global), cron OVH (2.6/8.4). ✓
- **Placeholders :** aucun TBD ; les tâches UI portent des contrats structurels précis (routes, classes, comportements, assertions) — code intégral fourni pour sécurité/tracking/analytics, le reste suit les patterns existants documentés. ✓
- **Cohérence types :** signatures alignées entre tâches (TrackingResult/Provider/Sync, SettingsService, NotificationService, AnalyticsCollector/Query). `ReviewRequestService` consomme `AppMailer::sendReviewRequest` (défini 2.4). `CronCommand` étendu en 6.3 comme annoncé en 2.6. ✓
