feat(admin): liste des commandes + KPIs de vente (P4) (#70)
All checks were successful
CI / secret-scan (push) Successful in 9s
CI / php-lint (push) Successful in 22s
CI / static-tests (push) Successful in 47s
CI / js-tests (push) Successful in 24s

This commit is contained in:
Corentin JOGUET 2026-06-19 20:24:33 +02:00
parent 6c431af197
commit 1d56d5b574
10 changed files with 519 additions and 5 deletions

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Response;
use App\Order\OrderQueryRepository;
/**
* Liste des commandes back-office (P4, domaine 7). GET /admin/orders, permission
* order.read. Lecture seule : pas de transition de statut ici (deliver/cancel =
* ecrans operationnels kitchen/counter, hors back-office MVP).
*
* Non `final` : les tests sous-classent (seam db()/orderQuery()).
*/
class OrderAdminController extends AdminController
{
/**
* @param array<string, string> $params
*/
public function index(array $params = []): Response
{
$guard = $this->guard('order.read');
if ($guard instanceof Response) {
return $guard;
}
return $this->adminView('admin/orders/index', [
'title' => 'Commandes - Wakdo Admin',
'activeNav' => 'orders',
'orders' => $this->orderQuery()->recent(50),
], $guard);
}
protected function orderQuery(): OrderQueryRepository
{
return new OrderQueryRepository($this->db());
}
}

View file

@ -6,6 +6,7 @@ namespace App\Controllers;
use App\Catalogue\StatsRepository; use App\Catalogue\StatsRepository;
use App\Core\Response; use App\Core\Response;
use App\Order\OrderQueryRepository;
/** /**
* Tableau de bord statistiques (mlt domaine 11). GET /admin/stats, permission * Tableau de bord statistiques (mlt domaine 11). GET /admin/stats, permission
@ -33,6 +34,7 @@ class StatsController extends AdminController
'activeNav' => 'stats', 'activeNav' => 'stats',
'counts' => $this->statsRepository()->counts(), 'counts' => $this->statsRepository()->counts(),
'stock' => $this->statsRepository()->stockHealth(), 'stock' => $this->statsRepository()->stockHealth(),
'sales' => $this->orderQuery()->salesKpis(),
], $guard); ], $guard);
} }
@ -40,4 +42,9 @@ class StatsController extends AdminController
{ {
return new StatsRepository($this->db()); return new StatsRepository($this->db());
} }
protected function orderQuery(): OrderQueryRepository
{
return new OrderQueryRepository($this->db());
}
} }

View file

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Order;
use App\Core\DatabaseInterface;
/**
* Lecture du domaine commande pour le back-office (P4) : liste des commandes
* recentes (order.read) + KPIs de vente du tableau de bord (stats.read).
*
* Separe de OrderRepository (ecriture : createPending / pay) pour garder le
* read-side leger -- il ne depend que de DatabaseInterface, pas des repos
* catalogue. Non `final` : seam de test (sous-classe -> double sans base).
*/
class OrderQueryRepository
{
public function __construct(private readonly DatabaseInterface $db)
{
}
/**
* Commandes les plus recentes (tous statuts confondus), pour la liste admin.
* Triees de la plus recente a la plus ancienne. $limit borne [1, 200] et
* interpole comme ENTIER (pas de bind : LIMIT n'accepte pas de parametre lie
* avec ATTR_EMULATE_PREPARES=false).
*
* @return list<array<string, mixed>>
*/
public function recent(int $limit = 50): array
{
$limit = max(1, min(200, $limit));
return $this->db->fetchAll(
'SELECT order_number, service_mode, service_tag, status, total_ttc_cents, created_at, paid_at '
. 'FROM customer_order ORDER BY created_at DESC, id DESC LIMIT ' . $limit,
);
}
/**
* KPIs de vente : CA encaisse (statuts paid + delivered), nombre de commandes
* encaissees, panier moyen, CA et nombre du JOUR, total de commandes, et la
* repartition par statut. Le CA exclut les commandes pending_payment (non
* encaissees) et cancelled.
*
* @return array{revenue_cents:int, paid_count:int, avg_basket_cents:int, revenue_today_cents:int, paid_count_today:int, total_orders:int, by_status:array<string,int>}
*/
public function salesKpis(): array
{
$t = $this->db->fetch(
"SELECT
COALESCE(SUM(CASE WHEN status IN ('paid','delivered') THEN total_ttc_cents ELSE 0 END), 0) AS revenue,
COALESCE(SUM(status IN ('paid','delivered')), 0) AS paid_count,
COALESCE(SUM(CASE WHEN status IN ('paid','delivered') AND created_at >= CURDATE() THEN total_ttc_cents ELSE 0 END), 0) AS revenue_today,
COALESCE(SUM(status IN ('paid','delivered') AND created_at >= CURDATE()), 0) AS paid_count_today,
COUNT(*) AS total_orders
FROM customer_order",
) ?? [];
$revenue = (int) ($t['revenue'] ?? 0);
$paid = (int) ($t['paid_count'] ?? 0);
$byStatus = [];
foreach ($this->db->fetchAll('SELECT status, COUNT(*) AS n FROM customer_order GROUP BY status') as $r) {
$byStatus[(string) ($r['status'] ?? '')] = (int) ($r['n'] ?? 0);
}
return [
'revenue_cents' => $revenue,
'paid_count' => $paid,
'avg_basket_cents' => $paid > 0 ? intdiv($revenue, $paid) : 0,
'revenue_today_cents' => (int) ($t['revenue_today'] ?? 0),
'paid_count_today' => (int) ($t['paid_count_today'] ?? 0),
'total_orders' => (int) ($t['total_orders'] ?? 0),
'by_status' => $byStatus,
];
}
}

View file

@ -114,10 +114,15 @@ $navClass = static function (string $code, string $current): string {
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if ($can('stats.read')): ?> <?php if ($can('stats.read') || $can('order.read')): ?>
<div class="sidebar-section"> <div class="sidebar-section">
<div class="sidebar-section-label">Pilotage</div> <div class="sidebar-section-label">Pilotage</div>
<?php if ($can('stats.read')): ?>
<a href="/admin/stats" class="<?= $navClass('stats', $active) ?>">Statistiques</a> <a href="/admin/stats" class="<?= $navClass('stats', $active) ?>">Statistiques</a>
<?php endif; ?>
<?php if ($can('order.read')): ?>
<a href="/admin/orders" class="<?= $navClass('orders', $active) ?>">Commandes</a>
<?php endif; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
@ -135,8 +140,7 @@ $navClass = static function (string $code, string $current): string {
<?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).
avec leur route respective : Commandes (order.read) -- domaine P4.
*/ ?> */ ?>
</nav> </nav>

View file

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
/**
* Liste des commandes (order.read), injectee dans admin/layout.php. Lecture seule :
* numero, mode, chevalet, statut, total ttc, date. Tri du plus recent au plus ancien
* (cf. OrderQueryRepository::recent). Toute valeur est echappee (RG-T15).
*
* @var list<array<string, mixed>> $orders
*/
$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
$euros = static fn (mixed $cents): string => number_format(((int) $cents) / 100, 2, ',', ' ') . ' EUR';
$modeLabel = static fn (string $m): string => match ($m) {
'dine_in' => 'Sur place',
'takeaway' => 'A emporter',
'drive' => 'Drive',
default => $m,
};
$statusLabel = static fn (string $s): string => match ($s) {
'pending_payment' => 'En attente',
'paid' => 'Payee',
'delivered' => 'Livree',
'cancelled' => 'Annulee',
default => $s,
};
$statusPill = static fn (string $s): string => match ($s) {
'paid', 'delivered' => 'pill-success',
'cancelled' => 'pill-danger',
default => 'pill-warning',
};
/** @var list<array<string, mixed>> $rows */
$rows = isset($orders) && is_array($orders) ? $orders : [];
?>
<section class="admin-section" aria-labelledby="orders-heading">
<h1 id="orders-heading" class="admin-section__title">Commandes</h1>
<p class="admin-section__sub"><?= count($rows) ?> commande(s) recente(s)</p>
<?php if ($rows === []): ?>
<p class="admin-empty">Aucune commande pour le moment.</p>
<?php else: ?>
<table class="admin-table">
<thead>
<tr>
<th>Numero</th>
<th>Mode</th>
<th>Chevalet</th>
<th>Statut</th>
<th>Total</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $o): ?>
<tr>
<td><strong><?= $esc($o['order_number'] ?? '') ?></strong></td>
<td><?= $esc($modeLabel((string) ($o['service_mode'] ?? ''))) ?></td>
<td><?= ($o['service_tag'] ?? '') !== '' ? $esc($o['service_tag']) : '—' ?></td>
<td><span class="pill <?= $esc($statusPill((string) ($o['status'] ?? ''))) ?>"><?= $esc($statusLabel((string) ($o['status'] ?? ''))) ?></span></td>
<td><?= $esc($euros($o['total_ttc_cents'] ?? 0)) ?></td>
<td><?= $esc($o['created_at'] ?? '') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</section>

View file

@ -42,7 +42,49 @@ $cards = [
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">Statistiques</h1> <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> <p class="page-subtitle">Ventes, sante du catalogue et du stock.</p>
</div>
</div>
<?php
/** @var array<string, mixed> $salesData */
$salesData = isset($sales) && is_array($sales) ? $sales : [];
$byStatus = is_array($salesData['by_status'] ?? null) ? $salesData['by_status'] : [];
$euros = static fn (mixed $cents): string => number_format(((int) $cents) / 100, 2, ',', ' ') . ' EUR';
?>
<div class="page-header">
<div>
<h2 class="page-title">Ventes</h2>
<p class="page-subtitle"><?= $esc((int) ($salesData['total_orders'] ?? 0)) ?> commande(s) au total — <?= $esc((int) ($salesData['paid_count_today'] ?? 0)) ?> payee(s) aujourd'hui.</p>
</div>
</div>
<div class="stats-cards">
<div class="stat-card">
<div class="stat-card__value"><?= $esc($euros($salesData['revenue_cents'] ?? 0)) ?></div>
<div class="stat-card__label">CA encaisse</div>
<div class="stat-card__sub muted"><?= $esc($euros($salesData['revenue_today_cents'] ?? 0)) ?> aujourd'hui</div>
</div>
<div class="stat-card">
<div class="stat-card__value"><?= $esc((int) ($salesData['paid_count'] ?? 0)) ?></div>
<div class="stat-card__label">Commandes payees</div>
<div class="stat-card__sub muted"><?= $esc((int) ($salesData['paid_count_today'] ?? 0)) ?> aujourd'hui</div>
</div>
<div class="stat-card">
<div class="stat-card__value"><?= $esc($euros($salesData['avg_basket_cents'] ?? 0)) ?></div>
<div class="stat-card__label">Panier moyen</div>
<div class="stat-card__sub muted">par commande payee</div>
</div>
<div class="stat-card">
<div class="stat-card__value"><?= $esc((int) ($salesData['total_orders'] ?? 0)) ?></div>
<div class="stat-card__label">Commandes totales</div>
<div class="stat-card__sub muted"><?= $esc((int) ($byStatus['pending_payment'] ?? 0)) ?> en attente</div>
</div>
</div>
<div class="page-header">
<div>
<h2 class="page-title">Catalogue</h2>
</div> </div>
</div> </div>

View file

@ -20,6 +20,7 @@ use App\Controllers\HomeController;
use App\Controllers\IngredientController; use App\Controllers\IngredientController;
use App\Controllers\MeController; use App\Controllers\MeController;
use App\Controllers\MenuController; use App\Controllers\MenuController;
use App\Controllers\OrderAdminController;
use App\Controllers\OrderController; use App\Controllers\OrderController;
use App\Controllers\PasswordResetController; use App\Controllers\PasswordResetController;
use App\Controllers\ProductController; use App\Controllers\ProductController;
@ -105,6 +106,9 @@ try {
// catalogue + sante stock (RG-T21) ; KPIs de vente avec les commandes (P4). // catalogue + sante stock (RG-T21) ; KPIs de vente avec les commandes (P4).
$router->add('GET', '/admin/stats', [StatsController::class, 'index']); $router->add('GET', '/admin/stats', [StatsController::class, 'index']);
// Commandes (P4, order.read) : liste lecture seule du domaine commande.
$router->add('GET', '/admin/orders', [OrderAdminController::class, 'index']);
// Gestion des comptes (mlt domaine 10). user.read (liste) ; user.create/update/ // Gestion des comptes (mlt domaine 10). user.read (liste) ; user.create/update/
// deactivate. TOUTES les mutations = PIN equipier + audit (RG-T13/14). {id} = un // deactivate. TOUTES les mutations = PIN equipier + audit (RG-T13/14). {id} = un
// seul segment (pas de collision avec /edit, /deactivate, /reset-pin, /erase). // seul segment (pas de collision avec /edit, /deactivate, /reset-pin, /erase).

View file

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Tests\Integration;
use PHPUnit\Framework\TestCase;
use Throwable;
use App\Core\Config;
use App\Core\Database;
use App\Order\OrderQueryRepository;
/**
* OrderQueryRepository (read-side admin) contre une vraie MariaDB (schema migre).
* Auto-skip si WAKDO_DB_TESTS != 1. Insere des commandes connues (order_number
* prefixe IT-<suffix>) et verifie les KPIs de vente (delta vs baseline) + la liste
* recente. Nettoyage par prefixe en tearDown.
*/
final class OrderQueryRepositoryDbTest extends TestCase
{
private Database $db;
private string $suffix = '';
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->suffix = bin2hex(random_bytes(4));
}
protected function tearDown(): void
{
if ($this->suffix === '') {
return;
}
$this->db->execute(
'DELETE FROM customer_order WHERE order_number LIKE :p',
['p' => 'IT-' . $this->suffix . '%'],
);
}
private function insertOrder(string $number, string $status, int $ttc): void
{
$ht = (int) round($ttc / 1.1);
$this->db->execute(
"INSERT INTO customer_order (order_number, idempotency_key, source, service_mode, status, "
. 'total_ht_cents, total_vat_cents, total_ttc_cents) '
. "VALUES (:num, :key, 'kiosk', 'takeaway', :st, :ht, :vat, :ttc)",
['num' => $number, 'key' => $number . '-k', 'st' => $status, 'ht' => $ht, 'vat' => $ttc - $ht, 'ttc' => $ttc],
);
}
public function testSalesKpisCountsPaidRevenueOnly(): void
{
$repo = new OrderQueryRepository($this->db);
$before = $repo->salesKpis();
$this->insertOrder('IT-' . $this->suffix . '-P', 'paid', 1000);
$this->insertOrder('IT-' . $this->suffix . '-N', 'pending_payment', 500);
$after = $repo->salesKpis();
// Le CA ne compte QUE le paid (pas le pending_payment).
self::assertSame($before['revenue_cents'] + 1000, $after['revenue_cents']);
self::assertSame($before['paid_count'] + 1, $after['paid_count']);
self::assertSame($before['total_orders'] + 2, $after['total_orders']);
self::assertGreaterThanOrEqual(1, $after['by_status']['paid'] ?? 0);
self::assertGreaterThanOrEqual(1, $after['by_status']['pending_payment'] ?? 0);
self::assertSame(intdiv($after['revenue_cents'], max(1, $after['paid_count'])), $after['avg_basket_cents']);
}
public function testRecentListsInsertedOrdersWithExpectedColumns(): void
{
$repo = new OrderQueryRepository($this->db);
$num = 'IT-' . $this->suffix . '-P';
$this->insertOrder($num, 'paid', 1000);
$recent = $repo->recent(200);
$numbers = array_column($recent, 'order_number');
self::assertContains($num, $numbers, 'la commande inseree doit apparaitre dans recent()');
$row = $recent[(int) array_search($num, $numbers, true)];
self::assertSame('paid', (string) $row['status']);
self::assertSame(1000, (int) $row['total_ttc_cents']);
self::assertSame('takeaway', (string) $row['service_mode']);
self::assertArrayHasKey('created_at', $row);
}
public function testRecentRespectsLimit(): void
{
$repo = new OrderQueryRepository($this->db);
self::assertLessThanOrEqual(3, count($repo->recent(3)));
}
}

View file

@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Admin;
use PHPUnit\Framework\TestCase;
use App\Auth\SessionManager;
use App\Controllers\OrderAdminController;
use App\Core\Config;
use App\Core\Database;
use App\Core\DatabaseInterface;
use App\Core\Request;
use App\Order\OrderQueryRepository;
use App\Tests\Support\FakeDatabase;
/**
* Stub d'OrderQueryRepository : liste canned (rendu de la table teste sans base ;
* les requetes sont couvertes par OrderQueryRepositoryDbTest).
*/
final class StubRecentOrders extends OrderQueryRepository
{
public function recent(int $limit = 50): array
{
return [
['order_number' => 'K42', 'service_mode' => 'dine_in', 'service_tag' => '261', 'status' => 'paid', 'total_ttc_cents' => 1990, 'created_at' => '2026-06-19 12:00:00', 'paid_at' => '2026-06-19 12:01:00'],
['order_number' => 'K43', 'service_mode' => 'takeaway', 'service_tag' => null, 'status' => 'pending_payment', 'total_ttc_cents' => 800, 'created_at' => '2026-06-19 12:05:00', 'paid_at' => null],
];
}
}
final class TestOrderAdminController extends OrderAdminController
{
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 orderQuery(): OrderQueryRepository
{
return new StubRecentOrders($this->fakeDb);
}
}
final class OrderAdminControllerTest 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 = ['order.read'];
return $db;
}
private function controller(FakeDatabase $db): TestOrderAdminController
{
$request = new Request('GET', '/admin/orders', [], [], '', '203.0.113.5');
return new TestOrderAdminController($request, new Config(), new Database(new Config()), $this->session, $db);
}
public function testRequiresOrderRead(): void
{
$db = $this->permittedDb();
$db->canResult = false;
self::assertSame(403, $this->controller($db)->index()->status());
}
public function testRendersOrdersList(): void
{
$response = $this->controller($this->permittedDb())->index();
self::assertSame(200, $response->status());
$body = $response->body();
self::assertStringContainsString('Commandes', $body);
self::assertStringContainsString('K42', $body);
self::assertStringContainsString('Sur place', $body); // dine_in -> libelle
self::assertStringContainsString('261', $body); // chevalet
self::assertStringContainsString('19,90 EUR', $body); // total 1990c formate
self::assertStringContainsString('Payee', $body); // statut paid
self::assertStringContainsString('A emporter', $body); // takeaway -> libelle
}
}

View file

@ -12,6 +12,7 @@ use App\Core\Config;
use App\Core\Database; use App\Core\Database;
use App\Core\DatabaseInterface; use App\Core\DatabaseInterface;
use App\Core\Request; use App\Core\Request;
use App\Order\OrderQueryRepository;
use App\Tests\Support\FakeDatabase; use App\Tests\Support\FakeDatabase;
/** /**
@ -43,6 +44,27 @@ final class StubStatsRepository extends StatsRepository
} }
} }
/**
* Stub d'OrderQueryRepository : KPIs de vente canned (rendu du bloc Ventes teste
* sans base ; les agregats sont couverts par OrderQueryRepositoryDbTest).
*/
final class StubOrderQueryRepository extends OrderQueryRepository
{
public function salesKpis(): array
{
return [
'revenue_cents' => 20800, 'paid_count' => 8, 'avg_basket_cents' => 2600,
'revenue_today_cents' => 5200, 'paid_count_today' => 2, 'total_orders' => 11,
'by_status' => ['paid' => 8, 'pending_payment' => 2, 'cancelled' => 1],
];
}
public function recent(int $limit = 50): array
{
return [];
}
}
final class TestStatsController extends StatsController final class TestStatsController extends StatsController
{ {
public function __construct( public function __construct(
@ -69,6 +91,11 @@ final class TestStatsController extends StatsController
{ {
return new StubStatsRepository($this->fakeDb); return new StubStatsRepository($this->fakeDb);
} }
protected function orderQuery(): OrderQueryRepository
{
return new StubOrderQueryRepository($this->fakeDb);
}
} }
final class StatsControllerTest extends TestCase final class StatsControllerTest extends TestCase
@ -140,5 +167,8 @@ final class StatsControllerTest extends TestCase
self::assertStringContainsString('53', $body); // compteur produits self::assertStringContainsString('53', $body); // compteur produits
self::assertStringContainsString('Cheddar', $body); // alerte stock critique self::assertStringContainsString('Cheddar', $body); // alerte stock critique
self::assertStringContainsString('critical', $body); // bande self::assertStringContainsString('critical', $body); // bande
self::assertStringContainsString('Ventes', $body); // section KPIs vente
self::assertStringContainsString('CA encaisse', $body);
self::assertStringContainsString('208,00 EUR', $body); // revenue_cents 20800 formate
} }
} }