From fe07e06ee1a814d3f23acbc17276f73df805f336 Mon Sep 17 00:00:00 2001 From: Imugiii Date: Wed, 17 Jun 2026 10:36:32 +0000 Subject: [PATCH] feat(admin): tableau de bord statistiques (catalogue + sante stock RG-T21) (P3) Lot S du cycle P3 (Users/RBAC/Stats). Tableau de bord stats.read sur /admin/stats, landing par defaut du role manager (ferme le 404 : la route existe enfin). - StatsRepository : counts() (compteurs catalogue produits/menus/categories/ ingredients, total + actifs/disponibles via SUM(bool)) ; stockHealth() (repartition des ingredients actifs par bande normal/low/critical, liste d'alerte triee du plus critique, reutilise IngredientRepository::stockBand = source unique de la derivation RG-T21). - StatsController (stats.read) + vue admin/stats/index (cartes KPI + table d'alerte stock) + lien nav Pilotage (gated stats.read) + route. - Les KPIs de vente (CA, volumes) dependent du domaine commande et sont explicitement differes en P4. Tests : unit 233, integration 263 / 794 assertions (WAKDO_DB_TESTS=1), PHPStan L6. --- src/app/Catalogue/StatsRepository.php | 91 +++++++++++++ src/app/Controllers/StatsController.php | 43 ++++++ src/app/Views/admin/layout.php | 7 + src/app/Views/admin/stats/index.php | 99 ++++++++++++++ src/public/admin/assets/css/admin.css | 35 +++++ src/public/admin/index.php | 4 + tests/Integration/StatsRepositoryDbTest.php | 84 ++++++++++++ tests/Unit/Admin/StatsControllerTest.php | 144 ++++++++++++++++++++ 8 files changed, 507 insertions(+) create mode 100644 src/app/Catalogue/StatsRepository.php create mode 100644 src/app/Controllers/StatsController.php create mode 100644 src/app/Views/admin/stats/index.php create mode 100644 tests/Integration/StatsRepositoryDbTest.php create mode 100644 tests/Unit/Admin/StatsControllerTest.php diff --git a/src/app/Catalogue/StatsRepository.php b/src/app/Catalogue/StatsRepository.php new file mode 100644 index 0000000..e5a38d6 --- /dev/null +++ b/src/app/Catalogue/StatsRepository.php @@ -0,0 +1,91 @@ +> + */ + public function counts(): array + { + return [ + 'products' => $this->pair('SELECT COUNT(*) AS total, COALESCE(SUM(is_available = 1), 0) AS n FROM product', 'available'), + 'categories' => $this->pair('SELECT COUNT(*) AS total, COALESCE(SUM(is_active = 1), 0) AS n FROM category', 'active'), + 'menus' => $this->pair('SELECT COUNT(*) AS total, COALESCE(SUM(is_available = 1), 0) AS n FROM menu', 'available'), + 'ingredients' => $this->pair('SELECT COUNT(*) AS total, COALESCE(SUM(is_active = 1), 0) AS n FROM ingredient', 'active'), + ]; + } + + /** + * @return array + */ + private function pair(string $sql, string $key): array + { + $row = $this->db->fetch($sql) ?? []; + + return ['total' => (int) ($row['total'] ?? 0), $key => (int) ($row['n'] ?? 0)]; + } + + /** + * Sante du stock : repartition des ingredients ACTIFS par bande (RG-T21, via + * IngredientRepository::stockBand = source unique de la derivation) + liste + * d'alerte (bandes low/critical), triee du plus critique au moins critique. + * + * @return array{active_total:int, bands:array{normal:int,low:int,critical:int}, alerts:list} + */ + public function stockHealth(): array + { + $rows = $this->db->fetchAll( + 'SELECT name, stock_quantity, stock_capacity, low_stock_pct, critical_stock_pct ' + . 'FROM ingredient WHERE is_active = 1 ORDER BY name', + ); + + $bands = ['normal' => 0, 'low' => 0, 'critical' => 0]; + $alerts = []; + foreach ($rows as $r) { + $qty = (int) ($r['stock_quantity'] ?? 0); + $cap = (int) ($r['stock_capacity'] ?? 0); + $band = IngredientRepository::stockBand( + $qty, + $cap, + (int) ($r['low_stock_pct'] ?? 0), + (int) ($r['critical_stock_pct'] ?? 0), + ); + $bands[$band]++; + if ($band !== 'normal') { + $alerts[] = [ + 'name' => (string) ($r['name'] ?? ''), + 'stock_pct' => IngredientRepository::stockPct($qty, $cap), + 'stock_band' => $band, + ]; + } + } + + // Plus critique (pourcentage le plus bas) en tete. + usort($alerts, static fn (array $a, array $b): int => $a['stock_pct'] <=> $b['stock_pct']); + + return ['active_total' => count($rows), 'bands' => $bands, 'alerts' => $alerts]; + } +} diff --git a/src/app/Controllers/StatsController.php b/src/app/Controllers/StatsController.php new file mode 100644 index 0000000..a3c3f0f --- /dev/null +++ b/src/app/Controllers/StatsController.php @@ -0,0 +1,43 @@ + $params + */ + public function index(array $params = []): Response + { + $guard = $this->guard('stats.read'); + if ($guard instanceof Response) { + return $guard; + } + + return $this->adminView('admin/stats/index', [ + 'title' => 'Statistiques - Wakdo Admin', + 'activeNav' => 'stats', + 'counts' => $this->statsRepository()->counts(), + 'stock' => $this->statsRepository()->stockHealth(), + ], $guard); + } + + protected function statsRepository(): StatsRepository + { + return new StatsRepository($this->db()); + } +} diff --git a/src/app/Views/admin/layout.php b/src/app/Views/admin/layout.php index 8d0036a..99a4aa8 100644 --- a/src/app/Views/admin/layout.php +++ b/src/app/Views/admin/layout.php @@ -118,6 +118,13 @@ $navClass = static function (string $code, string $current): string { + + + + } $stock + */ + +$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +/** @var array> $c */ +$c = isset($counts) && is_array($counts) ? $counts : []; +/** @var array $s */ +$s = isset($stock) && is_array($stock) ? $stock : ['active_total' => 0, 'bands' => ['normal' => 0, 'low' => 0, 'critical' => 0], 'alerts' => []]; +$bands = is_array($s['bands'] ?? null) ? $s['bands'] : ['normal' => 0, 'low' => 0, 'critical' => 0]; +/** @var list> $alerts */ +$alerts = is_array($s['alerts'] ?? null) ? $s['alerts'] : []; + +$bandLabel = static fn (string $b): string => match ($b) { + 'critical' => 'Critique', + 'low' => 'Alerte', + default => 'Normal', +}; +$bandPill = static fn (string $b): string => match ($b) { + 'critical' => 'pill-danger', + 'low' => 'pill-warning', + default => 'pill-success', +}; + +/** @var list $cards */ +$cards = [ + ['key' => 'products', 'label' => 'Produits', 'sub' => 'available'], + ['key' => 'menus', 'label' => 'Menus', 'sub' => 'available'], + ['key' => 'categories', 'label' => 'Categories', 'sub' => 'active'], + ['key' => 'ingredients', 'label' => 'Ingredients', 'sub' => 'active'], +]; +?> + + +
+ + +
+
+
+
+
+ +
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + +
IngredientStockEtat
Aucun ingredient en alerte ou rupture. Stock sain.
% + +
+
+
diff --git a/src/public/admin/assets/css/admin.css b/src/public/admin/assets/css/admin.css index 102c027..f16b7a8 100644 --- a/src/public/admin/assets/css/admin.css +++ b/src/public/admin/assets/css/admin.css @@ -1173,3 +1173,38 @@ tbody td.mono { ::-webkit-scrollbar-thumb:hover { background: var(--color-text-muted); } + +/* ============================================================ + Statistiques — cartes KPI (tableau de bord stats.read) + ============================================================ */ +.stats-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; +} + +.stat-card { + background: var(--color-white); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 1.25rem 1.5rem; +} + +.stat-card__value { + font-size: 2rem; + font-weight: 700; + color: var(--color-text); + line-height: 1.1; +} + +.stat-card__label { + font-weight: 600; + color: var(--color-text); + margin-top: 0.25rem; +} + +.stat-card__sub { + font-size: 0.875rem; + margin-top: 0.125rem; +} diff --git a/src/public/admin/index.php b/src/public/admin/index.php index 332a9f3..be27c16 100644 --- a/src/public/admin/index.php +++ b/src/public/admin/index.php @@ -22,6 +22,7 @@ use App\Controllers\MenuController; use App\Controllers\PasswordResetController; use App\Controllers\ProductController; use App\Controllers\ProfileController; +use App\Controllers\StatsController; use App\Core\Autoloader; use App\Core\Config; use App\Core\Database; @@ -69,6 +70,9 @@ try { // Back-office (P3) : pages rendues serveur sous /admin, gardees par SessionGuard. $router->add('GET', '/admin/dashboard', [DashboardController::class, 'index']); + // Tableau de bord statistiques (stats.read) : landing du role manager. KPIs + // catalogue + sante stock (RG-T21) ; KPIs de vente avec les commandes (P4). + $router->add('GET', '/admin/stats', [StatsController::class, 'index']); // CRUD Categories (permission category.manage). Pas de suppression dure : toggle is_active. $router->add('GET', '/admin/categories', [CategoryController::class, 'index']); diff --git a/tests/Integration/StatsRepositoryDbTest.php b/tests/Integration/StatsRepositoryDbTest.php new file mode 100644 index 0000000..e7ef8e9 --- /dev/null +++ b/tests/Integration/StatsRepositoryDbTest.php @@ -0,0 +1,84 @@ +db = new Database(new Config()); + try { + $this->db->fetch('SELECT 1'); + } catch (Throwable $exception) { + self::markTestSkipped('Base injoignable: ' . $exception->getMessage()); + } + $this->ing = 'it-stat-' . bin2hex(random_bytes(4)); + } + + protected function tearDown(): void + { + if ($this->ing === '') { + return; + } + $id = (int) ($this->db->fetch('SELECT id FROM ingredient WHERE name = :n', ['n' => $this->ing])['id'] ?? 0); + if ($id > 0) { + $this->db->execute('DELETE FROM stock_movement WHERE ingredient_id = :id', ['id' => $id]); + $this->db->execute('DELETE FROM ingredient WHERE id = :id', ['id' => $id]); + } + } + + public function testCountsReflectSeededCatalogue(): void + { + $stats = new StatsRepository($this->db); + $counts = $stats->counts(); + + // Le seed pose un catalogue non vide (9 categories, 53 produits, 13 menus). + self::assertGreaterThan(0, $counts['categories']['total']); + self::assertGreaterThan(0, $counts['products']['total']); + self::assertGreaterThan(0, $counts['menus']['total']); + // 'available'/'active' borne par 'total'. + self::assertLessThanOrEqual($counts['products']['total'], $counts['products']['available']); + self::assertLessThanOrEqual($counts['categories']['total'], $counts['categories']['active']); + } + + public function testStockHealthClassifiesCriticalIngredient(): void + { + $ingredients = new IngredientRepository($this->db); + // Ingredient actif sous le seuil critique (2/100 <= 5%). + $ingredients->create([ + 'name' => $this->ing, 'unit' => 'portion', 'stock_quantity' => 2, 'stock_capacity' => 100, + 'pack_size' => 1, 'pack_label' => null, 'low_stock_pct' => 10, 'critical_stock_pct' => 5, 'is_active' => 1, + ]); + + $health = (new StatsRepository($this->db))->stockHealth(); + + self::assertGreaterThanOrEqual(1, $health['bands']['critical']); + // L'ingredient apparait dans la liste d'alerte (bas/critique). + $names = array_map(static fn (array $a): string => (string) $a['name'], $health['alerts']); + self::assertContains($this->ing, $names); + // Le total des bandes = nb d'ingredients actifs. + $sum = $health['bands']['normal'] + $health['bands']['low'] + $health['bands']['critical']; + self::assertSame($health['active_total'], $sum); + } +} diff --git a/tests/Unit/Admin/StatsControllerTest.php b/tests/Unit/Admin/StatsControllerTest.php new file mode 100644 index 0000000..8c89af3 --- /dev/null +++ b/tests/Unit/Admin/StatsControllerTest.php @@ -0,0 +1,144 @@ + ['total' => 53, 'available' => 50], + 'categories' => ['total' => 9, 'active' => 9], + 'menus' => ['total' => 13, 'available' => 12], + 'ingredients' => ['total' => 7, 'active' => 6], + ]; + } + + public function stockHealth(): array + { + return [ + 'active_total' => 6, + 'bands' => ['normal' => 4, 'low' => 1, 'critical' => 1], + 'alerts' => [ + ['name' => 'Cheddar', 'stock_pct' => 3, 'stock_band' => 'critical'], + ['name' => 'Cornichon', 'stock_pct' => 8, 'stock_band' => 'low'], + ], + ]; + } +} + +final class TestStatsController extends StatsController +{ + public function __construct( + Request $request, + Config $config, + Database $database, + private readonly SessionManager $testSession, + private readonly FakeDatabase $fakeDb, + ) { + parent::__construct($request, $config, $database); + } + + protected function sessionManager(): SessionManager + { + return $this->testSession; + } + + protected function db(): DatabaseInterface + { + return $this->fakeDb; + } + + protected function statsRepository(): StatsRepository + { + return new StubStatsRepository($this->fakeDb); + } +} + +final class StatsControllerTest extends TestCase +{ + /** @var list */ + private array $touchedKeys = []; + private SessionManager $session; + + protected function setUp(): void + { + $this->setEnv('SESSION_LIFETIME_IDLE', '14400'); + $this->setEnv('SESSION_LIFETIME_ABSOLUTE', '36000'); + $this->session = new SessionManager(new Config(), true); + $now = time(); + $this->session->set('user_id', 1); + $this->session->set('role_id', 2); + $this->session->set('logged_in_at', $now - 100); + $this->session->set('last_activity', $now - 50); + } + + protected function tearDown(): void + { + foreach ($this->touchedKeys as $key) { + putenv($key); + } + $this->touchedKeys = []; + } + + private function setEnv(string $key, string $value): void + { + $this->touchedKeys[] = $key; + putenv($key . '=' . $value); + } + + private function permittedDb(): FakeDatabase + { + $db = new FakeDatabase(); + $db->guardUserRow = ['is_active' => 1]; + $db->userDisplayRow = ['first_name' => 'Manon', 'last_name' => 'G', 'role_label' => 'Manager']; + $db->canResult = true; + $db->permissionCodes = ['stats.read']; + + return $db; + } + + private function controller(FakeDatabase $db): TestStatsController + { + $request = new Request('GET', '/admin/stats', [], [], '', '203.0.113.5'); + + return new TestStatsController($request, new Config(), new Database(new Config()), $this->session, $db); + } + + public function testRequiresStatsRead(): void + { + $db = $this->permittedDb(); + $db->canResult = false; + + self::assertSame(403, $this->controller($db)->index()->status()); + } + + public function testRendersCatalogueCountsAndStockAlerts(): void + { + $db = $this->permittedDb(); + $response = $this->controller($db)->index(); + + self::assertSame(200, $response->status()); + $body = $response->body(); + self::assertStringContainsString('Statistiques', $body); + self::assertStringContainsString('53', $body); // compteur produits + self::assertStringContainsString('Cheddar', $body); // alerte stock critique + self::assertStringContainsString('critical', $body); // bande + } +} -- 2.45.3