release: dev -> main v0.2.0 #93

Merged
Corentin merged 96 commits from dev into main 2026-06-23 10:09:58 +02:00
10 changed files with 519 additions and 5 deletions
Showing only changes of commit 1d56d5b574 - Show all commits

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\Core\Response;
use App\Order\OrderQueryRepository;
/**
* Tableau de bord statistiques (mlt domaine 11). GET /admin/stats, permission
@ -33,6 +34,7 @@ class StatsController extends AdminController
'activeNav' => 'stats',
'counts' => $this->statsRepository()->counts(),
'stock' => $this->statsRepository()->stockHealth(),
'sales' => $this->orderQuery()->salesKpis(),
], $guard);
}
@ -40,4 +42,9 @@ class StatsController extends AdminController
{
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>
<?php endif; ?>
<?php if ($can('stats.read')): ?>
<?php if ($can('stats.read') || $can('order.read')): ?>
<div class="sidebar-section">
<div class="sidebar-section-label">Pilotage</div>
<a href="/admin/stats" class="<?= $navClass('stats', $active) ?>">Statistiques</a>
<?php if ($can('stats.read')): ?>
<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>
<?php endif; ?>
@ -135,8 +140,7 @@ $navClass = static function (string $code, string $current): string {
<?php /*
Items de nav volontairement absents tant que leur page n'existe pas
(un lien vers une route non enregistree renvoie un 404). A reactiver
avec leur route respective : Commandes (order.read) -- domaine P4.
(un lien vers une route non enregistree renvoie un 404).
*/ ?>
</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>
<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>

View file

@ -20,6 +20,7 @@ use App\Controllers\HomeController;
use App\Controllers\IngredientController;
use App\Controllers\MeController;
use App\Controllers\MenuController;
use App\Controllers\OrderAdminController;
use App\Controllers\OrderController;
use App\Controllers\PasswordResetController;
use App\Controllers\ProductController;
@ -105,6 +106,9 @@ try {
// catalogue + sante stock (RG-T21) ; KPIs de vente avec les commandes (P4).
$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/
// deactivate. TOUTES les mutations = PIN equipier + audit (RG-T13/14). {id} = un
// 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\DatabaseInterface;
use App\Core\Request;
use App\Order\OrderQueryRepository;
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
{
public function __construct(
@ -69,6 +91,11 @@ final class TestStatsController extends StatsController
{
return new StubStatsRepository($this->fakeDb);
}
protected function orderQuery(): OrderQueryRepository
{
return new StubOrderQueryRepository($this->fakeDb);
}
}
final class StatsControllerTest extends TestCase
@ -140,5 +167,8 @@ final class StatsControllerTest extends TestCase
self::assertStringContainsString('53', $body); // compteur produits
self::assertStringContainsString('Cheddar', $body); // alerte stock critique
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
}
}