feat(admin): tableau de bord statistiques (catalogue + sante stock RG-T21) (P3) (#37)
This commit is contained in:
parent
1ecd78324c
commit
9c2844c116
8 changed files with 507 additions and 0 deletions
91
src/app/Catalogue/StatsRepository.php
Normal file
91
src/app/Catalogue/StatsRepository.php
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Catalogue;
|
||||||
|
|
||||||
|
use App\Core\DatabaseInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agregats de pilotage du back-office (tableau de bord, permission stats.read).
|
||||||
|
* KPIs sur les donnees DISPONIBLES en P3 : compteurs de catalogue et sante du
|
||||||
|
* stock (bandes RG-T21). Les KPIs de vente (CA, volumes) dependent du domaine
|
||||||
|
* commande (P4) et sont hors perimetre ici.
|
||||||
|
*
|
||||||
|
* Non `final` : les tests sous-classent pour stubber les agregats sans base.
|
||||||
|
*/
|
||||||
|
class StatsRepository
|
||||||
|
{
|
||||||
|
public function __construct(private readonly DatabaseInterface $db)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compteurs de catalogue : total + sous-ensemble actif/disponible par entite.
|
||||||
|
* SUM(bool) compte les lignes verifiant le predicat (COALESCE pour table vide).
|
||||||
|
* Chaque entree porte 'total' + la cle de sous-ensemble ('available' pour
|
||||||
|
* product/menu, 'active' pour category/ingredient).
|
||||||
|
*
|
||||||
|
* @return array<string, array<string, int>>
|
||||||
|
*/
|
||||||
|
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<string, int>
|
||||||
|
*/
|
||||||
|
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<array{name:string,stock_pct:int,stock_band:string}>}
|
||||||
|
*/
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/app/Controllers/StatsController.php
Normal file
43
src/app/Controllers/StatsController.php
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Catalogue\StatsRepository;
|
||||||
|
use App\Core\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tableau de bord statistiques (mlt domaine 11). GET /admin/stats, permission
|
||||||
|
* stats.read (landing par defaut du role manager, cf. seed role.default_route).
|
||||||
|
* En P3, les KPIs portent sur les donnees disponibles : compteurs de catalogue
|
||||||
|
* et sante du stock (RG-T21). Les KPIs de vente (CA, volumes) viendront avec le
|
||||||
|
* domaine commande (P4).
|
||||||
|
*
|
||||||
|
* Non `final` : les tests sous-classent (seam db()/statsRepository()).
|
||||||
|
*/
|
||||||
|
class StatsController extends AdminController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -118,6 +118,13 @@ $navClass = static function (string $code, string $current): string {
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($can('stats.read')): ?>
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<div class="sidebar-section-label">Pilotage</div>
|
||||||
|
<a href="/admin/stats" class="<?= $navClass('stats', $active) ?>">Statistiques</a>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php /*
|
<?php /*
|
||||||
Items de nav volontairement absents tant que leur page n'existe pas
|
Items de nav volontairement absents tant que leur page n'existe pas
|
||||||
(un lien vers une route non enregistree renvoie un 404). A reactiver
|
(un lien vers une route non enregistree renvoie un 404). A reactiver
|
||||||
|
|
|
||||||
99
src/app/Views/admin/stats/index.php
Normal file
99
src/app/Views/admin/stats/index.php
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tableau de bord statistiques (stats.read), injecte dans admin/layout.php.
|
||||||
|
* KPIs disponibles en P3 : compteurs de catalogue + sante du stock (RG-T21).
|
||||||
|
* Les KPIs de vente (CA, volumes) arrivent avec le domaine commande (P4).
|
||||||
|
*
|
||||||
|
* @var array{products:array{total:int,available:int}, categories:array{total:int,active:int}, menus:array{total:int,available:int}, ingredients:array{total:int,active:int}} $counts
|
||||||
|
* @var array{active_total:int, bands:array{normal:int,low:int,critical:int}, alerts:list<array{name:string,stock_pct:int,stock_band:string}>} $stock
|
||||||
|
*/
|
||||||
|
|
||||||
|
$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
|
||||||
|
/** @var array<string, array<string, int>> $c */
|
||||||
|
$c = isset($counts) && is_array($counts) ? $counts : [];
|
||||||
|
/** @var array<string, mixed> $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<array<string, mixed>> $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<array{key:string, label:string, sub:string}> $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'],
|
||||||
|
];
|
||||||
|
?>
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">Statistiques</h1>
|
||||||
|
<p class="page-subtitle">Sante du catalogue et du stock. Les indicateurs de vente arriveront avec les commandes (P4).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-cards">
|
||||||
|
<?php foreach ($cards as $card): ?>
|
||||||
|
<?php
|
||||||
|
$entity = is_array($c[$card['key']] ?? null) ? $c[$card['key']] : [];
|
||||||
|
$total = (int) ($entity['total'] ?? 0);
|
||||||
|
$sub = (int) ($entity[$card['sub']] ?? 0);
|
||||||
|
$subLabel = $card['sub'] === 'available' ? 'disponibles' : 'actifs';
|
||||||
|
?>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-card__value"><?= $esc($total) ?></div>
|
||||||
|
<div class="stat-card__label"><?= $esc($card['label']) ?></div>
|
||||||
|
<div class="stat-card__sub muted"><?= $esc($sub) ?> <?= $esc($subLabel) ?></div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="page-title">Sante du stock</h2>
|
||||||
|
<p class="page-subtitle"><?= $esc((int) ($s['active_total'] ?? 0)) ?> ingredients actifs — normal <?= $esc((int) $bands['normal']) ?>, alerte <?= $esc((int) $bands['low']) ?>, critique <?= $esc((int) $bands['critical']) ?> (RG-T21).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container">
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Ingredient</th>
|
||||||
|
<th>Stock</th>
|
||||||
|
<th>Etat</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php if ($alerts === []): ?>
|
||||||
|
<tr><td colspan="3" class="muted">Aucun ingredient en alerte ou rupture. Stock sain.</td></tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php foreach ($alerts as $a): ?>
|
||||||
|
<?php $band = (string) ($a['stock_band'] ?? 'normal'); ?>
|
||||||
|
<tr>
|
||||||
|
<td class="fw-600"><?= $esc($a['name'] ?? '') ?></td>
|
||||||
|
<td><?= $esc((int) ($a['stock_pct'] ?? 0)) ?>%</td>
|
||||||
|
<td>
|
||||||
|
<span class="pill <?= $esc($bandPill($band)) ?>" data-band="<?= $esc($band) ?>"><?= $esc($bandLabel($band)) ?></span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -1173,3 +1173,38 @@ tbody td.mono {
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--color-text-muted);
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ use App\Controllers\MenuController;
|
||||||
use App\Controllers\PasswordResetController;
|
use App\Controllers\PasswordResetController;
|
||||||
use App\Controllers\ProductController;
|
use App\Controllers\ProductController;
|
||||||
use App\Controllers\ProfileController;
|
use App\Controllers\ProfileController;
|
||||||
|
use App\Controllers\StatsController;
|
||||||
use App\Core\Autoloader;
|
use App\Core\Autoloader;
|
||||||
use App\Core\Config;
|
use App\Core\Config;
|
||||||
use App\Core\Database;
|
use App\Core\Database;
|
||||||
|
|
@ -69,6 +70,9 @@ try {
|
||||||
|
|
||||||
// Back-office (P3) : pages rendues serveur sous /admin, gardees par SessionGuard.
|
// Back-office (P3) : pages rendues serveur sous /admin, gardees par SessionGuard.
|
||||||
$router->add('GET', '/admin/dashboard', [DashboardController::class, 'index']);
|
$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.
|
// CRUD Categories (permission category.manage). Pas de suppression dure : toggle is_active.
|
||||||
$router->add('GET', '/admin/categories', [CategoryController::class, 'index']);
|
$router->add('GET', '/admin/categories', [CategoryController::class, 'index']);
|
||||||
|
|
|
||||||
84
tests/Integration/StatsRepositoryDbTest.php
Normal file
84
tests/Integration/StatsRepositoryDbTest.php
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Integration;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Throwable;
|
||||||
|
use App\Catalogue\IngredientRepository;
|
||||||
|
use App\Catalogue\StatsRepository;
|
||||||
|
use App\Core\Config;
|
||||||
|
use App\Core\Database;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StatsRepository contre une vraie MariaDB (schema migre + seede). Auto-skip si
|
||||||
|
* WAKDO_DB_TESTS != 1. Verifie les compteurs catalogue et la sante stock (bandes
|
||||||
|
* RG-T21) sur des donnees reelles. Ingredient jetable (it-stat-*) nettoye en
|
||||||
|
* tearDown (mouvements d'abord, FK RESTRICT).
|
||||||
|
*/
|
||||||
|
final class StatsRepositoryDbTest extends TestCase
|
||||||
|
{
|
||||||
|
private Database $db;
|
||||||
|
private string $ing = '';
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
if (getenv('WAKDO_DB_TESTS') !== '1') {
|
||||||
|
self::markTestSkipped('Tests DB desactives (definir WAKDO_DB_TESTS=1 + DB_*).');
|
||||||
|
}
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
144
tests/Unit/Admin/StatsControllerTest.php
Normal file
144
tests/Unit/Admin/StatsControllerTest.php
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Admin;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use App\Auth\SessionManager;
|
||||||
|
use App\Catalogue\StatsRepository;
|
||||||
|
use App\Controllers\StatsController;
|
||||||
|
use App\Core\Config;
|
||||||
|
use App\Core\Database;
|
||||||
|
use App\Core\DatabaseInterface;
|
||||||
|
use App\Core\Request;
|
||||||
|
use App\Tests\Support\FakeDatabase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stub de StatsRepository : KPIs canned, sans base. Permet de tester le rendu du
|
||||||
|
* controleur independamment des requetes d'agregation (couvertes par le DbTest).
|
||||||
|
*/
|
||||||
|
final class StubStatsRepository extends StatsRepository
|
||||||
|
{
|
||||||
|
public function counts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'products' => ['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<string> */
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue