feat(admin): liste des commandes + KPIs de vente (P4)
All checks were successful
CI / static-tests (push) Successful in 47s
CI / secret-scan (pull_request) Successful in 9s
CI / php-lint (pull_request) Successful in 23s
CI / static-tests (pull_request) Successful in 47s
CI / js-tests (pull_request) Successful in 25s
CI / secret-scan (push) Successful in 10s
CI / php-lint (push) Successful in 22s
CI / js-tests (push) Successful in 29s
All checks were successful
CI / static-tests (push) Successful in 47s
CI / secret-scan (pull_request) Successful in 9s
CI / php-lint (pull_request) Successful in 23s
CI / static-tests (pull_request) Successful in 47s
CI / js-tests (pull_request) Successful in 25s
CI / secret-scan (push) Successful in 10s
CI / php-lint (push) Successful in 22s
CI / js-tests (push) Successful in 29s
Ouvre le domaine commande cote back-office (les commandes existent depuis P4 1a/1b et, depuis la borne L4, sont reellement creees). - App\Order\OrderQueryRepository (read-side, DatabaseInterface seul) : recent($limit) pour la liste admin + salesKpis() (CA encaisse paid+delivered, nb payees, panier moyen, CA/nb du jour, total, repartition par statut). - OrderAdminController : GET /admin/orders, permission order.read, liste lecture seule (numero, mode, chevalet, statut, total ttc, date). Les transitions deliver/cancel restent aux ecrans operationnels (hors back-office MVP). - StatsController : ferme la dette "KPIs de vente differes P4" -> section Ventes dans /admin/stats (CA, commandes payees, panier moyen, total). Hook orderQuery() = seam. - Nav "Commandes" (order.read) reactivee dans la sidebar (etait volontairement absente tant que la route n'existait pas). - Tests : OrderQueryRepositoryDbTest (integration, vraie DB : 3) + OrderAdminControllerTest (double : guard 403/200 + rendu) + StatsControllerTest (section Ventes). 350 PHP verts, PHPStan L6 propre. Routes verifiees live (302 -> login, gardees par SessionGuard).
This commit is contained in:
parent
6c431af197
commit
70388318ea
10 changed files with 519 additions and 5 deletions
40
src/app/Controllers/OrderAdminController.php
Normal file
40
src/app/Controllers/OrderAdminController.php
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
79
src/app/Order/OrderQueryRepository.php
Normal file
79
src/app/Order/OrderQueryRepository.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
73
src/app/Views/admin/orders/index.php
Normal file
73
src/app/Views/admin/orders/index.php
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
103
tests/Integration/OrderQueryRepositoryDbTest.php
Normal file
103
tests/Integration/OrderQueryRepositoryDbTest.php
Normal 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)));
|
||||
}
|
||||
}
|
||||
132
tests/Unit/Admin/OrderAdminControllerTest.php
Normal file
132
tests/Unit/Admin/OrderAdminControllerTest.php
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue