# Spec — « Vulcan Admin » : partie admin v2 complète

> Validé par le propriétaire le 2026-07-12 (« GO — design validé »).
> Contexte : Symfony 7.2 / PHP 8.2+ / MySQL (`vulcan_affutage`) / Doctrine ORM 3. Pas de repo git à ce jour.
> Principe cardinal : **construction parallèle** — l'ancien admin `/admin` et tout le front client restent intacts et fonctionnels. L'admin v2 vit sous `/gestion`.

## 1. Objectif

Back-office neuf, professionnel et ergonomique, couvrant : dashboard utile, gestion des commandes (avec suivi La Poste automatisé + communication client), produits, ateliers (créneaux/réservations), campagnes de bandeaux côté client, informations du site (téléphone/email/horaires/fermetures/liens), statistiques fiables (anti-bot, unicité visiteur), centre de notifications, données de démonstration, le tout testé de bout en bout.

Décisions propriétaire (2026-07-12) :
- Clé API La Poste (Okapi) fournie → stockée en `.env.local` sous `LAPOSTE_API_KEY`, jamais commitée.
- Ateliers = module complet Atelier/Créneau/Réservation (front public de réservation viendra plus tard).
- Google Business Profile : pas d'API — horaires gérés localement + bouton raccourci vers la fiche GBP.
- Email d'avis : lien avis Google configurable dans les paramètres.

## 2. Architecture générale

### 2.1 Routes (préfixe `/gestion`)

| Route | Nom | Rôle |
|---|---|---|
| `GET/POST /gestion/login` | `gestion_login` | Login admin v2 (throttlé, CSRF) |
| `POST /gestion/logout` | `gestion_logout` | Logout |
| `GET /gestion` | `gestion_dashboard` | Dashboard |
| `GET /gestion/commandes` | `gestion_commandes` | Liste commandes (onglets) |
| `GET /gestion/commandes/{reference}` | `gestion_commande_detail` | Détail commande |
| `GET /gestion/produits` | `gestion_produits` | Liste produits |
| `GET/POST /gestion/produits/nouveau` | `gestion_produit_new` | Création |
| `GET/POST /gestion/produits/{id}` | `gestion_produit_edit` | Édition |
| `GET /gestion/ateliers` | `gestion_ateliers` | Ateliers + planning |
| `GET/POST /gestion/ateliers/nouveau`, `/{id}` | `gestion_atelier_*` | CRUD atelier |
| `GET /gestion/ateliers/creneaux/{id}` | `gestion_creneau_detail` | Créneau + réservations |
| `GET /gestion/bandeaux` + CRUD | `gestion_bandeaux*` | Campagnes bandeau |
| `GET/POST /gestion/parametres` | `gestion_parametres` | Infos site + livraisons |
| `GET /gestion/statistiques` | `gestion_stats` | Stats + rapports |
| `GET /gestion/notifications` | `gestion_notifications` | Centre de notifications |
| `POST /gestion/api/...` | `gestion_api_*` | Endpoints JSON (statut commande, suivi, toggle produit, slots home, notifications lues, recherche palette, données graphiques) |

Endpoint public de collecte stats : `POST /t` (beacon, hors firewall, sans session).

### 2.2 Sécurité (firewall dédié)

`config/packages/security.yaml` :
- provider `gestion_admins` : entity `App\Entity\Admin`, property `pseudo`.
- firewall `gestion` : `pattern: ^/gestion`, lazy, provider ci-dessus, `custom_authenticator: App\Security\GestionAuthenticator` (form login custom pour garder le design), `logout: { path: gestion_logout }`, `login_throttling: { max_attempts: 5, interval: '1 minute' }`.
- `access_control` : `^/gestion/login` → PUBLIC_ACCESS ; `^/gestion` → ROLE_ADMIN.
- Le firewall `main` existant reste inert (sessions manuelles client/admin v1 non touchées). `bcrypt` déjà configuré pour `Admin`.
- Authenticator : migration de session à la connexion, message d'erreur générique, redirection cible sûre (pas d'open redirect).
- CSRF : formulaires Twig (`csrf_token`) + fetch JSON avec header `X-CSRF-Token` vérifié côté contrôleur. Toute mutation en POST.
- Uploads : réutilise le durcissement existant (finfo whitelist jpg/png/webp, ≤ 5 Mo).
- `StatVisitListener` existant : ajouter skip du préfixe `/gestion` (comme `/admin`).

### 2.3 Données — nouvelles entités (une migration additive)

**Banner** (`banner`) : `id`, `message` VARCHAR(500), `type` enum string (`promo`|`info`), `mode` (`statique`|`defilant`), `startAt` DATETIME, `endAt` DATETIME, `actif` BOOL, `priorite` INT (défaut 0), `createdAt`. Index (`actif`,`startAt`,`endAt`).

**Setting** (`setting`) : `id`, `skey` VARCHAR(100) UNIQUE, `svalue` LONGTEXT NULL, `updatedAt`. Clés initiales : `contact.phone`, `contact.email`, `hours.week` (JSON 7 jours, matin/après-midi ou fermé), `hours.exceptional` (JSON `[{date,label,ferme|horaires}]`), `links.google_review`, `links.gbp_dashboard`, `links.instagram`, `links.facebook`, `notify.client_on_delivery` (`1`/`0`), `shop.address`.

**Atelier** (`atelier`) : `id`, `titre` VARCHAR(150), `description` TEXT NULL, `prix` DECIMAL(10,2), `dureeMinutes` INT, `capaciteDefaut` INT, `actif` BOOL, `createdAt`.

**AtelierCreneau** (`atelier_creneau`) : `id`, `atelier_id` FK, `startAt` DATETIME, `capacite` INT, `statut` enum (`ouvert`|`ferme`|`annule`), `note` VARCHAR(255) NULL. Index (`startAt`).

**AtelierReservation** (`atelier_reservation`) : `id`, `creneau_id` FK, `client_id` FK NULL, `nom`, `prenom`, `email` NULL, `telephone` NULL, `places` INT (défaut 1), `statut` enum (`confirmee`|`attente`|`annulee`), `source` enum (`admin`|`site`), `createdAt`.

**AdminNotification** (`admin_notification`) : `id`, `type` enum (`vente`|`livraison`|`suivi_anomalie`|`reservation`|`systeme`), `titre` VARCHAR(200), `message` VARCHAR(500) NULL, `url` VARCHAR(300) NULL, `createdAt`, `readAt` NULL. Index (`createdAt`, `readAt`).

**AnalyticsHit** (`analytics_hit`) : `id`, `visitorHash` CHAR(64), `path` VARCHAR(500), `pageType` VARCHAR(20) (`home`|`produit`|`listing`|`panier`|`checkout`|`autre`), `produit_id` FK NULL, `categorie` VARCHAR(50) NULL, `eventType` VARCHAR(20) (`pageview`|`add_to_cart`|`purchase`|`account_created`), `device` VARCHAR(10), `referrerHost` VARCHAR(150) NULL, `hitAt` DATETIME. Index (`hitAt`), (`eventType`,`hitAt`), (`produit_id`), (`visitorHash`,`hitAt`).

**AnalyticsDaily** (`analytics_daily`) : `id`, `day` DATE UNIQUE, `visitors` INT, `pageviews` INT, `addToCarts` INT, `orders` INT, `revenue` DECIMAL(10,2), `accountsCreated` INT, `topPages` JSON, `topProducts` JSON, `topCategories` JSON, `computedAt`.

**Commande** — colonnes ajoutées : `carrier` VARCHAR(20) NULL (`libre`|`laposte`), `trackingUrl` VARCHAR(300) NULL (mode libre), `trackingEvents` JSON NULL, `trackingLastStatus` VARCHAR(30) NULL, `trackingLastCheckedAt` DATETIME NULL, `trackingDeliveredAt` DATETIME NULL, `reviewEmailSentAt` DATETIME NULL, `noteInterne` TEXT NULL.

`StatVisit` : conservé tel quel (ancien admin continue de fonctionner).

### 2.4 Services

- **`App\Service\Settings\SettingsService`** : `get(key, default)`, `set(key, value)`, `all()` ; cache applicatif (`cache.app`) invalidé à l'écriture. Fonction Twig `setting('...')` via `AppExtension`.
- **`App\Service\Tracking\TrackingProviderInterface`** : `supports(string $carrier): bool`, `fetch(string $num): TrackingResult`. `TrackingResult` = DTO (statut normalisé, libellé, events[], isFinal, deliveredAt?).
- **`LaPosteTrackingProvider`** : GET `https://api.laposte.fr/suivi/v2/idships/{num}?lang=fr_FR`, header `X-Okapi-Key: %env(LAPOSTE_API_KEY)%`, timeout 10 s. Mapping codes événements → statuts normalisés : `PRIS_EN_CHARGE` (DR1/DR2/PC1/PC2), `EN_TRANSIT` (ET1-4, EP1, DO1-3), `EN_LIVRAISON` (MD2), `DISPONIBLE_RETRAIT` (AG1), `LIVRE` (DI1/DI2), `PROBLEME` (PB1/PB2/ND1), `RETOUR` (RE1). Erreurs : 401 → notification admin `systeme` (clé invalide) ; 404 → statut `INCONNU` (numéro non trouvé, pas d'erreur) ; 429/5xx → retry au prochain cycle. Le mapping exact sera vérifié contre la réponse réelle de l'API pendant l'implémentation.
- **`MockTrackingProvider`** : actif si `LAPOSTE_API_KEY` vide OU `APP_TRACKING_MOCK=1`. Scénario déterministe dérivé du numéro (suffixe) + âge de l'expédition → progression réaliste des événements. Sert aussi aux tests PHPUnit.
- **`TrackingSyncService`** : `sync(Commande)` → fetch, diff avec `trackingEvents`, persiste, transitions : premier event → statut commande `SHIPPED` (si pas déjà) ; `LIVRE` → `DELIVERED` + `trackingDeliveredAt` + AdminNotification `livraison` + email client « colis livré » si `notify.client_on_delivery` et email présent ; `PROBLEME` → AdminNotification `suivi_anomalie`. `syncAllDue()` : commandes carrier=laposte, numéro présent, statut ∉ {DELIVERED, CANCELED, REFUSED}, `trackingLastCheckedAt` NULL ou > 2 h.
- **`ReviewRequestService`** : commandes DELIVERED, `trackingDeliveredAt` ≤ now−2 j, `reviewEmailSentAt` NULL, email client présent, `links.google_review` non vide → envoie email avis, marque `reviewEmailSentAt`.
- **`App\Service\Mail\AppMailer`** : centralise les nouveaux envois — `sendOrderShipped(Commande)`, `sendOrderStatusChanged(Commande)`, `sendOrderDelivered(Commande)`, `sendReviewRequest(Commande)`. From = `no-reply@vulcanaffutage.com`, reply-to = `contact.email` des settings. Templates DA 2.0 sous `templates/emails/v2/` (tables HTML compatibles clients mail, logo embarqué, palette forge/vert). Les anciens envois (activation, reset, confirmation commande) ne sont pas modifiés.
- **`BannerService`** : `getActiveBanner()` — `actif=1 AND startAt<=now<=endAt ORDER BY priorite DESC, id DESC LIMIT 1`, cache 60 s. Twig global léger.
- **`AnalyticsCollector`** : validation payload beacon (taille, types), calcul `visitorHash = sha256(selJour + ip + userAgent)` (sel quotidien stocké/tourné en Setting interne `analytics.salt.YYYY-MM-DD`, purge des anciens), détection bot (regex UA : bot/crawl/spider/headless/lighthouse/preview…), cap fréquence (> 60 hits/10 min par hash → ignoré), device par UA. Insert `AnalyticsHit`. Événements server-side : `purchase` (à la capture PayPal), `account_created` (à l'inscription) — insérés directement, fiables.
- **`AnalyticsQueryService`** : visiteurs uniques (`COUNT(DISTINCT visitorHash)`), pages vues, top pages/produits/catégories, funnel produit (vues → paniers → achats), appareils, référents, comptes créés, séries temporelles par période (7/30/90/365 j + libre). `computeDaily(date)` → upsert `AnalyticsDaily`. Purge hits bruts > 90 j (les agrégats restent).
- **`App\Command\CronCommand`** (`app:cron`) : idempotent, verrou (`symfony/lock` flock) — sync suivi due, emails d'avis dus, `computeDaily` (veille si absente), rotation sel, purge hits. Prod : cron OVH */15 min. Dev : exécution manuelle.

### 2.5 Côté client (modifications partagées, minimales)

1. **Bandeau** : include `_banner.html.twig` dans `base.html.twig`, au-dessus du header. Bande fine, fond forge, accent vert (info) / braise+vert (promo), texte échappé (`textContent`/Twig escape — aucun HTML injectable). Mode `defilant` = duplication du texte + animation `translateX` infinie CSS, pause au survol, désactivée sous `prefers-reduced-motion` (retombe en statique). Bouton fermer → `sessionStorage['vaBannerDismiss-{id}']`. Décalage layout géré (le header fixe reste calé sous la bande via variable CSS de hauteur).
2. **Beacon analytics** : `public/scripts/ds-analytics.js` chargé dans `base.html.twig` (defer) : pageview au chargement (path, pageType déduit, produitId si page produit via `data-` attribut, referrer host) via `navigator.sendBeacon('/t')` ; hook `add_to_cart` sur le flux `addToCartAjax` existant. Jamais chargé sous `/gestion` ni `/admin`.
3. **Settings dynamiques** : remplacement des valeurs en dur téléphone/email/horaires par `setting()` dans : footer (`base.html.twig`), page contact v2, section contact homepage v2, JSON-LD. Fallback = valeurs actuelles si setting vide.

Tout le reste du front : intact. Vérification de non-régression en fin de chantier.

## 3. UI admin — déclinaison DA 2.0 « côté atelier »

- **Chrome** : sidebar sombre fixe (fond `--v2-forge`/`--v2-basalt`, logo, nav verticale avec icônes Lucide-style stroked, badges compteurs — ex. commandes à traiter), topbar claire (recherche/palette Ctrl+K, cloche notifications avec pastille, bouton discret raccourcis ⚡ en dropdown, « Voir le site », menu compte/logout), contenu sur `--v2-steel-050`. Responsive : sidebar → barre inférieure ou burger sous 900 px.
- **Scope** : `body.adm`, feuilles `public/styles/ds-admin.css` (copie locale des tokens `--v2-*` + atomes `.adm-*`) et `public/scripts/ds-admin.js`. Base Twig indépendante `templates/gestion/_base.html.twig` (document complet : fonts Fraunces/Inter/IBM Plex Mono, toast v2 réutilisé — `ds-v2-toast.css/js`). Chart.js 4 vendorisé dans `public/vendor/chartjs/` (pas de CDN).
- **Signature visuelle** : mêmes tokens, mais dominance sombre latérale + accents mono (IBM Plex) sur les données ; KPI en Fraunces ; liseré « fil de lame » vert sur les cartes actives. Distinct de la boutique au premier regard, même famille.
- **Composants** : `.adm-card`, `.adm-table` (tri, hover, responsive scroll), `.adm-kpi`, `.adm-badge` (statuts commandes/campagnes), `.adm-tabs`, `.adm-form` (reprend le floating-label `.chk-field`), `.adm-modal` (confirmations — remplace `confirm()`), `.adm-timeline` (suivi colis), `.adm-gauge` (remplissage créneaux), palette `.adm-palette`.
- **Dashboard** : rangée KPI (CA mois + delta, à traiter, visiteurs 7 j + delta, comptes 31 j, prochaines résas) ; liste « À traiter » ; fil notifications (10 dernières, lien tout voir) ; sparklines 14 j (visiteurs, ventes).

## 4. Modules — comportements clés

- **Commandes** : onglets par état, recherche (référence, nom, email), pagination (KnpPaginator présent). Détail : infos client/adresse/paiement/lignes, note interne éditable, actions — changement de statut (dropdown enum + **checkbox « Communiquer ce changement au client »**, active seulement si email client présent), gestion suivi (select transporteur : **Libre** → champ numéro + lien optionnel, zéro API ; **La Poste (Colissimo/Suivi/Chronopost)** → validation format, timeline auto, bouton « Vérifier maintenant »), clic numéro = copie presse-papiers, lien facture PDF existant. Emails déclenchés selon checkbox : expédié (numéro + lien cliquable ; formulation adaptée si libre sans lien), statut modifié, livré (auto selon toggle).
- **Produits** : table vignette/libellé/type/prix/statuts/actions, filtres type + statut + recherche ; pages création/édition full (champs actuels : libellé, prix, description, type, modèle TypeCouteau, variante, statuts) ; gestionnaire d'images (visionneuse modale : aperçu, upload multiple, réordonner ↔ renommage `{id}-{n}`, supprimer) ; slots homepage (liste ordonnée 1-20, réutilise `home_priority`) ; suppression protégée (refus si commandes liées — comportement existant conservé) avec modal de confirmation.
- **Ateliers** : liste cartes (titre, prix, durée, capacité, actif) ; CRUD ; planning des créneaux à venir groupés par mois (ajout rapide : date/heure/capacité) ; détail créneau : jauge places, liste réservations, ajout manuel (nom/tél/email/places — résa téléphone), annulation avec motif. Notification admin à chaque nouvelle réservation (source site, plus tard).
- **Bandeaux** : liste avec badges d'état calculés (Programmé/Actif/Expiré/Désactivé), création/édition : message (500 c.), type (`promo`/`info` — préconfigurations visuelles), mode (statique/défilant), plage `du JJ/MM/AAAA HH:MM au JJ/MM/AAAA HH:MM`, priorité, **aperçu live** dans le formulaire (rendu exact du bandeau client). Chevauchements permis — priorité tranche.
- **Paramètres** : sections Contact (téléphone affiché partout, email public), Horaires (7 jours, fermé/matin/après-midi), Fermetures exceptionnelles (liste date+libellé, affichées sur le site aux dates concernées), Liens (avis Google, fiche GBP — bouton raccourci ouvre la fiche, Instagram/Facebook), **Livraisons** (CRUD entité `Livraison` : libellé, prix, type, visibilité — permet d'ajouter les services La Poste manquants), Notifications (toggle email client à la livraison).
- **Statistiques** : sélecteur de période, cartes synthèse (visiteurs uniques, pages vues, ajouts panier, commandes, CA, taux de conversion, comptes créés + total comptes), graphique principal (visiteurs/jour + ventes), top pages / top produits (avec funnel vues→panier→achat) / top catégories, appareils, référents. Onglet **Rapports** : journalier (table par jour, deltas) + mensuel (agrégats par mois) + export CSV. Mention du filtrage bots (compteur hits exclus).
- **Notifications** : cloche topbar (pastille non-lus, dropdown 8 dernières), page complète (filtres par type, marquer lu / tout marquer lu). Générées par : capture PayPal réussie (« Nouvelle vente ! »), livraison colis, anomalie suivi, erreur config API, réservation atelier. Polling 60 s (endpoint JSON léger).

## 5. Statistiques — règles de mesure

- **Collecte** : beacon JS uniquement pour les pageviews/paniers (bots sans JS = invisibles) ; purchase/account_created en server-side (fiabilité).
- **Unicité** : `visitorHash` quotidien salé (sha256(sel + IP + UA)) — pas de cookie, pas de consentement requis (approche type Plausible), fermer/rouvrir ne double-compte pas. « Visiteurs » = COUNT DISTINCT hash ; « visites/sessions » = épisodes espacés de > 30 min par hash (calcul requête).
- **Anti-bot** : pas de JS = pas compté ; regex UA ; cap fréquence par hash ; exclusion totale des chemins `/gestion`, `/admin`, `/t`.
- **Rétention** : hits bruts 90 j, agrégats quotidiens illimités. `AnalyticsDaily` calculé par cron + à la demande pour « aujourd'hui ».

## 6. Fixtures de démonstration (`--group=demo`)

3 `Livraison` (Colissimo domicile 6,90 € / Chronopost 12,90 € / Retrait à l'atelier 0 €) ; 8 clients ; ~25 commandes sur 60 j (tous statuts, adresses réalistes, lignes produits, paiements, numéros de suivi mock pour les expédiées) ; 3 ateliers (initiation affûtage, entretien couteaux, atelier enfant) + créneaux sur 6 semaines + réservations partielles ; 2 bandeaux (1 promo actif, 1 info programmé) ; settings par défaut (horaires actuels du site, téléphone/email actuels) ; ~10 notifications variées ; **60 j d'`AnalyticsHit` réalistes** (courbe hebdomadaire, produits pondérés, funnel cohérent) + `AnalyticsDaily` calculés. Fixtures existantes (`AppFixtures`) non modifiées.

## 7. Tests & QA

- **PHPUnit** (unit) : mapping La Poste → statuts normalisés ; `TrackingSyncService` transitions (mock provider) ; `ReviewRequestService` (2 j, idempotence, email absent) ; `SettingsService` cache ; `BannerService` sélection active/priorité ; `AnalyticsCollector` (hash, bot, cap) ; requêtes unicité.
- **PHPUnit** (fonctionnel, WebTestCase) : `/gestion/*` sans auth → redirect login ; login ok/ko/throttle ; dashboard 200 ; CRUD produit ; changement statut commande avec checkbox → email asserté (profiler mailer) ; beacon `/t` insère ; bandeau actif rendu sur `/`.
- **E2E navigateur (Chrome DevTools MCP)** : parcours admin complet (login → chaque module → chaque action clé, mobile 390 + desktop 1440, 0 erreur console) ; parcours client (bandeau affiché/fermable/défilant, pages v1+v2 intactes, achat simulé si PayPal sandbox dispo) ; suivi La Poste testé avec la clé réelle sur un numéro de test puis via mock.
- **Env de test** : `MAILER_DSN=null://null`, `APP_TRACKING_MOCK=1`, DB dédiée ou transactions.

## 8. Hors périmètre (explicitement)

Front public de réservation d'ateliers ; API Google Business ; avis internes ; refonte des anciens emails auth ; git init/CI (Pilier 0) ; montée Symfony 7.4 (PROD-CHECKLIST) ; file d'attente Messenger pour les mails (envois synchrones conservés).

## 9. Phasage d'implémentation (ordre de build)

1. **Socle** : migration entités + firewall/authenticator + `_base.html.twig` + `ds-admin.css/js` + login + dashboard squelette.
2. **Commandes + tracking** : liste/détail, providers La Poste/mock, sync service, emails v2, checkbox communication, cron.
3. **Produits** : CRUD + images + slots.
4. **Paramètres + bandeaux** : settings service + consommation client, CRUD livraisons, bannières + rendu client.
5. **Ateliers** : entités déjà migrées → CRUD + planning + réservations.
6. **Stats** : beacon + collector + requêtes + écrans + rapports + agrégats.
7. **Notifications + dashboard final** : producteurs d'événements, cloche, KPIs réels, palette Ctrl+K, raccourcis.
8. **Fixtures démo + tests + QA E2E + vérif non-régression client.**

Chaque phase livre un incrément fonctionnel testé ; l'ancien admin reste disponible en secours à tout moment.
